fix(ui-stdio): cancel EOF-exit timer on dispose; harden config defaults; doc fixes
Round 1 of Codex review on the extraction PR.
- (B) EOF-exit race: the 200ms flush-then-exit setTimeout was untracked, so a
fiber/HMR dispose within that window could not cancel it and the process
would still exit. Track the handle and clear it in the disposer; coalesce
re-entrant maybeExit() calls onto the one pending timer. Regression tests for
both (dispose-within-window cancels; repeated idle schedules once).
- (B/doc) ui-stdio rendering is global, not scoped by config.agent (faithful to
the original copies — agent scopes only input + the EOF-exit gate). Corrected
the README + Config JSDoc, which overclaimed "drive and render".
- (C) createStdioChat is exported and driven directly by tests/programmatic
callers that bypass schemastery validation, so default welcome/agent in the
helper (?? 'ready.'/'main') instead of trusting the cast. Test for empty config.
- (A/doc) docs/rfc/.../acp-snapshot-tests.md asserted the replay plugin
deliberately stays in examples/ ("don't split preemptively") — now false since
this PR packages it. Added a superseding note with the why (coverage gate).
All gates green: typecheck, lint, test:coverage (891, 100%), doc-sync,
test:e2e (6 keyless pass), test:snapshot unaffected.
This commit is contained in:
@@ -9,7 +9,7 @@ This package consolidates what were two near-identical copies under `examples/ec
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. |
|
||||
| `agent` | string | `'main'` | Id of the agent to drive and render. |
|
||||
| `agent` | string | `'main'` | Id of the agent that stdin **drives** (`send`/`steer`) and whose `agent/status` gates the EOF exit. Rendering is **not** scoped by it — see below. |
|
||||
|
||||
```yaml
|
||||
- id: ui-stdio
|
||||
@@ -20,6 +20,8 @@ This package consolidates what were two near-identical copies under `examples/ec
|
||||
|
||||
## Rendering
|
||||
|
||||
Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
|
||||
|
||||
- `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
|
||||
- `agent/turn-start` / `agent/turn-end` — a `[<agent> turn N]` header and a trailing `> ` prompt.
|
||||
- `session/event` — `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`.
|
||||
|
||||
@@ -31,7 +31,7 @@ export const inject = ['agents']
|
||||
export interface Config {
|
||||
/** Banner printed once on start, before the first `> ` prompt. */
|
||||
welcome?: string
|
||||
/** Id of the agent to drive and render. Defaults to `'main'`. */
|
||||
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
|
||||
agent?: string
|
||||
}
|
||||
|
||||
@@ -64,9 +64,12 @@ export interface StdioRuntime {
|
||||
* interface down.
|
||||
*/
|
||||
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
|
||||
// schemastery `.default()` guarantees these are set after validation.
|
||||
const welcome = config.welcome as string
|
||||
const agentId = config.agent as string
|
||||
// Default here too (not just via schemastery's `.default()`): this helper is
|
||||
// exported and called directly by tests / programmatic consumers that bypass
|
||||
// Loader validation, so it must be self-contained rather than trusting the
|
||||
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const agentId = config.agent ?? 'main'
|
||||
const { input, output, exit } = runtime
|
||||
|
||||
let inReasoning = false
|
||||
@@ -122,6 +125,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
let disposed = false
|
||||
let submittedWork = false
|
||||
let sawRunning = false
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const maybeExit = (): void => {
|
||||
if (disposed || !stdinClosed) return
|
||||
@@ -132,8 +136,14 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
const agent = ctx.agents.get(agentId)
|
||||
if (agent && agent.status !== 'idle') return // a turn is still running
|
||||
}
|
||||
// Let any final output flush, then exit.
|
||||
setTimeout(() => { exit(0) }, 200)
|
||||
// Let any final output flush, then exit. The handle is tracked so the
|
||||
// disposer can cancel it — a dispose within the flush window must not let
|
||||
// the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g.
|
||||
// repeated idle signals) coalesce onto the one pending timer.
|
||||
if (exitTimer !== undefined) {
|
||||
return // exit already scheduled — coalesce re-entrant calls
|
||||
}
|
||||
exitTimer = setTimeout(() => { exit(0) }, 200)
|
||||
}
|
||||
|
||||
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -166,6 +176,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
output.write(`${welcome}\n> `)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer)
|
||||
disposeStatusListener()
|
||||
reader.close()
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ describe('createStdioChat rendering', () => {
|
||||
expect(out.text()).toBe('hi there\n> ')
|
||||
})
|
||||
|
||||
it('falls back to default welcome/agent when called with empty config', async () => {
|
||||
// createStdioChat is exported and may be driven directly (bypassing the
|
||||
// Loader's schemastery validation), so it must default welcome/agent itself.
|
||||
const { out } = await setup({})
|
||||
expect(out.text()).toBe('ready.\n> ')
|
||||
// And it drives the default agent id 'main'.
|
||||
})
|
||||
|
||||
it('renders text-delta chunks verbatim', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
@@ -239,6 +247,24 @@ describe('createStdioChat EOF exit', () => {
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('schedules the exit only once when idle fires repeatedly', async () => {
|
||||
const { ctx, input, exit } = await setup()
|
||||
const agent = makeAgent('main', 'running')
|
||||
ctx.agents.register(agent)
|
||||
input.feed('work')
|
||||
await new Promise(r => setImmediate(r))
|
||||
ctx.emit('agent/status', agent, 'running') // sawRunning = true
|
||||
input.finish()
|
||||
await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
|
||||
;(agent as { status: AgentStatus }).status = 'idle'
|
||||
// Two idle signals while stdin is already closed: the first arms the timer,
|
||||
// the second must hit the already-scheduled guard, not arm a second.
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
ctx.emit('agent/status', agent, 'idle')
|
||||
await flushExit()
|
||||
expect(exit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not exit on an idle transition for a different agent', async () => {
|
||||
const { ctx, input, exit } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
@@ -279,6 +305,18 @@ describe('createStdioChat disposal (HMR safety)', () => {
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a scheduled exit if disposed within the flush window', async () => {
|
||||
const { fiber, input, exit } = await setup()
|
||||
// EOF with no work submitted schedules the 200ms flush-then-exit timer.
|
||||
input.finish()
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
|
||||
// Dispose BEFORE the timer fires: the tracked handle must be cleared.
|
||||
await fiber.dispose()
|
||||
await flushExit()
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops handling input after dispose', async () => {
|
||||
const { ctx, fiber, input } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
|
||||
Reference in New Issue
Block a user