diff --git a/.gitignore b/.gitignore index ecf5e96e57..2dcf9e39bf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ examples/*/*.jsonl examples/*/.sessions/ coverage/ .doc-typecheck-*/ +.humanize/ .vscode/ .DS_Store diff --git a/docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md index 5a0bdac0b2..f720f96354 100644 --- a/docs/rfc/implemented/2026-06-19-acp-snapshot-tests.md +++ b/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 diff --git a/packages/ui-stdio/README.md b/packages/ui-stdio/README.md index 8d605ce3e4..e49b0240b9 100644 --- a/packages/ui-stdio/README.md +++ b/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 `[ 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] …`. diff --git a/packages/ui-stdio/src/index.ts b/packages/ui-stdio/src/index.ts index d63abc95a5..d52370d1ed 100644 --- a/packages/ui-stdio/src/index.ts +++ b/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 | 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() } diff --git a/packages/ui-stdio/tests/ui-stdio.spec.ts b/packages/ui-stdio/tests/ui-stdio.spec.ts index a0226c9cf6..bd5e0f7f91 100644 --- a/packages/ui-stdio/tests/ui-stdio.spec.ts +++ b/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')