浏览代码

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.
Tianyi Cui 3 月之前
父节点
当前提交
4f291953ea

+ 1 - 0
.gitignore

@@ -10,5 +10,6 @@ examples/*/*.jsonl
 examples/*/.sessions/
 coverage/
 .doc-typecheck-*/
+.humanize/
 .vscode/
 .DS_Store

+ 2 - 0
docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md

@@ -66,6 +66,8 @@ Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the exe
 
 ### Example-local plugin, not a new package
 
+> **Superseded (2026-06-19, same day):** the replay plugin was subsequently promoted to its own package, `@deepseek-ai/dsh-llm-replay` (`packages/llm-replay/`), and the snapshot config now references it by package name. The reason was not a second consumer but the per-file 100% coverage gate: logic under `examples/` is not measured, so the derive/parse/replay branches were unguarded — packaging them subjects all branches to the gate. The paragraph below records the original (now-outdated) decision.
+
 The replay plugin lives at `examples/acp-agent/src/llm-replay.ts`, referenced from the snapshot config by relative path — exactly how echo-agent wires its [mock-llm.ts](../../../examples/echo-agent/src/mock-llm.ts). It is test/example infrastructure with one consumer; the capability-seams rule says not to split into a published `packages/` trio preemptively. It is promoted to a package only when a second example needs it.
 
 ### Two subcommands, replay in the default gate

+ 3 - 1
packages/ui-stdio/README.md

@@ -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] …`.

+ 17 - 6
packages/ui-stdio/src/index.ts

@@ -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()
     }

+ 38 - 0
packages/ui-stdio/tests/ui-stdio.spec.ts

@@ -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')