Переглянути джерело

fix(acp): address review of the terminal-card alignment (exit parse, background/error, capability snapshot)

Codex + an independent review pass found three real defects in the prior commit:

1. parseExitStatus could misreport a SUCCESSFUL command as a failure: a clean
   exit 0 appends no marker, so output ending in "[exit code: 5]" (no trailing
   newline) was read as the marker. Anchor the parse to a LEADING newline —
   renderResult always inserts one before a real marker, so a body that merely
   ends in marker-like text no longer matches. A narrow residual (a clean exit 0
   whose final line is exactly the marker) is inherent to the replay-only-sees-
   text design and documented; the complete fix (a structured exit on the event)
   is the RFC's named escape hatch.

2. A run_in_background start and an isError result were rendered as exited
   terminal cards with a false exit-0 pill. A background start returns a task-id
   ack (not a streamed terminal) and is no longer marked terminal; an isError
   result (spawn failure / abort) carries no exit pill.

3. The terminal capability was re-read live on the result path, so a second
   initialize between a call and its result could desync them (orphan
   terminal_output or clobbered card). Snapshot the capability per session at
   creation (SessionRecord.terminalEnabled) so call and result always agree.

Also reword the reference-parity claim: keeping the description as a content
block in terminal mode is a DELIBERATE divergence (claude-agent-acp drops it).
Tests added for each; with-key e2e still green.
Tianyi Cui 3 місяців тому
батько
коміт
9f6b96c555

+ 1 - 1
docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md

@@ -6,7 +6,7 @@ Status: implemented
 
 The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../proposed/2026-06-14-acp-agent-client-protocol.md) and `packages/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block.
 
-That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same — and the human-readable description rides as a separate content block, since a terminal card has no description slot.)
+That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.)
 
 ## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create`
 

+ 21 - 4
packages/acp/src/index.ts

@@ -141,6 +141,17 @@ interface SessionRecord {
    * tool state.
    */
   presenter: ToolPresenter
+  /**
+   * Whether THIS session renders shell tools as terminal cards — snapshotted
+   * from the client's `_meta.terminal_output` capability at session creation
+   * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
+   * session means the `tool_call` (which registers the terminal) and the matching
+   * `tool_call_update` (which streams its output) ALWAYS agree, even if a later
+   * `initialize` mutates the connection-level capability between them — otherwise
+   * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
+   * result terminal) or clobber the card (call terminal, result non-terminal).
+   */
+  terminalEnabled: boolean
   /**
    * The in-flight `session/prompt`, or `undefined` when none is pending. A
    * prompt resolves with a {@link StopReason} or rejects with an Error (a
@@ -290,7 +301,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
     const rec = sessions.get(session.header.id)
     if (rec === undefined) return
     streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
-      enabled: terminalOutputCap,
+      enabled: rec.terminalEnabled,
       cwd: session.header.cwd,
     })
     const inflight = rec.inflight
@@ -422,7 +433,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           agentOptions: agentOptions(config),
         })
         bySession.set(agent, sessionId)
-        sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), inflight: undefined })
+        sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined })
         return Promise.resolve({ sessionId })
       },
 
@@ -475,7 +486,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
             throw invalidParams('connection closed during session/load')
           }
           bySession.set(agent, params.sessionId)
-          const record: SessionRecord = { sessionId: params.sessionId, agent, presenter: makePresenter(), inflight: undefined }
+          // Snapshot the terminal capability ONCE for this session (used by both
+          // the replay below and the post-load live stream) so a later
+          // `initialize` can't desync the call/result of a tool card.
+          const terminalEnabled = terminalOutputCap
+          const record: SessionRecord = {
+            sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined,
+          }
           sessions.set(params.sessionId, record)
           // Replay the persisted event log to the client as session/update. Use
           // the raw event log (NOT deriveMessages, which drops assistant/chunk
@@ -492,7 +509,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           // so the record's presenter starts clean for the post-load live stream.
           const replayPresenter = makePresenter()
           const replayTerminal: TerminalRendering = {
-            enabled: terminalOutputCap,
+            enabled: terminalEnabled,
             cwd: agent.session.header.cwd,
           }
           for (const event of agent.session.events) {

+ 28 - 0
packages/acp/tests/turns.spec.ts

@@ -159,6 +159,34 @@ describe('acp bridge — turn outcomes', () => {
     expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
   })
 
+  it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
+    // The session is created with the capability ON. A SECOND initialize then
+    // turns it OFF at the connection level — but this session keeps its snapshot,
+    // so its bash call STILL renders as a terminal card (call + result agree).
+    // Without the snapshot, the result path would re-read the now-OFF capability
+    // and either clobber the card (content sent) or be inconsistent with the call.
+    harness = await makeBridgeHarness({
+      storageDir,
+      withBash: true,
+      script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
+    })
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
+    const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+    // A re-initialize that DROPS the capability after the session exists.
+    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+    await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
+
+    const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
+    if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
+    // Still a terminal card (the session's snapshot, not the mutated connection cap).
+    expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined()
+    const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
+    if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
+    // The result AGREES with the call: terminal output present, content omitted.
+    expect(update.content).toBeUndefined()
+    expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined()
+  })
+
   it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
     // A buggy tool whose presentCall throws must not fail the live turn — the
     // bridge's presenter contains the throw (logging via its onError sink) and

Різницю між файлами не показано, бо вона завелика
+ 0 - 1
packages/tool-bash/README.md


+ 59 - 29
packages/tool-bash/src/index.ts

@@ -139,23 +139,32 @@ export function renderResult(result: BashRunResult): string {
  * mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
  * use the bare command as an execute tool's title. The model-written
  * `description` (a readable summary) rides as a `content` text block shown ABOVE
- * the card, since a terminal card has no description slot — claude-agent-acp
- * likewise surfaces its description as a separate content block. `rawInput` still
- * carries the bare command for non-execute UIs that DO render it.
+ * the card. (Note: claude-agent-acp DROPS the description in terminal mode and
+ * shows only the card; surfacing it as a content block is a deliberate
+ * divergence here — we keep the human summary visible alongside the card.)
+ * `rawInput` still carries the bare command for non-execute UIs that DO render it.
  *
- * `terminal` marks the call so a capable UI renders a TERMINAL card. Its `cwd`
- * (header) is the model `workdir` when given — ABSOLUTE as-is, RELATIVE for the
- * UI bridge to resolve against the session cwd; when omitted entirely the bridge
- * fills the session workspace cwd (this PURE presenter, args only, can't see it).
+ * `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
+ * FOREGROUND run is a terminal: a `run_in_background` call returns a task id
+ * immediately (it never streams a terminal; its output is polled via
+ * `bash_output`), so it is NOT marked terminal and renders as an ordinary
+ * execute card. For a foreground run the `terminal.cwd` (header) is the model
+ * `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
+ * against the session cwd; when omitted the bridge fills the session workspace
+ * cwd (this PURE presenter, args only, can't see it).
  */
-function presentBashCall(args: { command: string; description: string; workdir?: string }): ToolCallPresentation {
-  return {
+type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
+
+function presentBashCall(args: BashCallArgs): ToolCallPresentation {
+  const base = {
     title: args.command,
-    kind: 'execute',
+    kind: 'execute' as const,
     rawInput: args.command,
-    content: [{ type: 'text', text: args.description }],
-    terminal: args.workdir !== undefined ? { cwd: args.workdir } : {},
+    content: [{ type: 'text' as const, text: args.description }],
   }
+  // A background start is not an interactive terminal — no terminal card.
+  if (args.run_in_background === true) return base
+  return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
 }
 
 /**
@@ -167,37 +176,58 @@ function presentBashCall(args: { command: string; description: string; workdir?:
  * support (the fences are a UI-only affordance, so they live here, not in the
  * model-facing result; the fenced body is trimmed of trailing blank lines for a
  * tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
- * / `terminal.signal`, parsed from the status markers `renderResult` appended
- * (this parse is the exact inverse of those markers — they co-evolve in this
- * file and a round-trip test guards the pair). A non-text result (unexpected for
- * bash) falls through to `undefined` (UI keeps the raw result).
+ * / `terminal.signal`, parsed from the status markers `renderResult` appended.
+ *
+ * Terminal output/exit is suppressed for results that are NOT a finished
+ * foreground run: a `run_in_background` start (`isBackground` — the text is a
+ * task-id ack, not a streamed run) and an `isError` result (a spawn failure or
+ * abort — there is no real process exit to pill, and the body is an error
+ * message, not `renderResult` output, so parsing it would be meaningless). Those
+ * fall back to the fenced `content` block with no terminal metadata. The bridge's
+ * orphan guard also drops a result terminal when the call wasn't terminal, so a
+ * background call (not marked terminal in `presentBashCall`) is doubly safe.
+ * A non-text result (unexpected for bash) falls through to `undefined`.
  */
-function presentBashResult(_args: unknown, result: ToolResult): ToolResultPresentation | undefined {
+function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
   const block = result.content.length === 1 ? result.content[0] : undefined
   if (block === undefined || block.type !== 'text') return undefined
   const raw = block.text
   const fenced = raw.replace(/\n+$/, '')
-  return {
-    content: [{ type: 'text', text: `\`\`\`console\n${fenced}\n\`\`\`` }],
-    terminal: { output: raw, ...parseExitStatus(raw) },
-  }
+  const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
+  const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
+  // No exit pill / terminal output for a background ack or an errored run.
+  if (isBackground || result.isError) return { content }
+  return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
 }
 
 /**
  * Recover the structured exit status from a rendered `renderResult` string — the
  * inverse of the status markers it appends. A `[killed by signal: SIG]` marker
  * yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
- * a clean run appends neither, so absent both we report `{exitCode:0}`. (A
- * trapped-timeout run that exits 0 has no signal/exit marker either and reads as
- * exitCode 0, which is accurate — it did exit 0.) `renderResult` always appends
- * the exit/signal marker LAST (after any timeout marker) onto a non-empty body,
- * so the marker is anchored at end-of-string here — output that merely CONTAINS
- * such text earlier is not mistaken for it.
+ * absent both we report `{exitCode:0}` (a clean run appends no marker — and a
+ * trapped-timeout run that exits 0 also has none and is accurately exit 0).
+ *
+ * Why parse rendered text at all: `presentResult` is replay-safe and on a
+ * `session/load` the ONLY thing persisted is this content text — the structured
+ * `BashRunResult` is long gone — so unless the exit were added to the persisted
+ * event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
+ * is the only channel. The match is anchored to a LEADING newline + end-of-string
+ * because `renderResult` always inserts a `\n` before the marker (line ~124) onto
+ * a non-empty body: a real marker is therefore always its own final line. That
+ * defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
+ * with no trailing newline — a clean exit 0 — no longer reads as a failure).
+ *
+ * KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
+ * whose body's FINAL line is itself exactly `\n[exit code: N]` (the program
+ * printed that line and nothing after) is still indistinguishable from a real
+ * marker and would show a wrong pill. This is display-only (execution and the
+ * model-facing text are unaffected) and narrow; the complete fix is to persist a
+ * structured exit on the result event, which the RFC names as the escape hatch.
  */
 function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
-  const signal = /\[killed by signal: ([^\]\n]+)\]$/.exec(text)
+  const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
   if (signal?.[1] !== undefined) return { signal: signal[1] }
-  const exit = /\[exit code: (\d+)\]$/.exec(text)
+  const exit = /\n\[exit code: (\d+)\]$/.exec(text)
   if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
   return { exitCode: 0 }
 }

+ 42 - 0
packages/tool-bash/tests/tools.spec.ts

@@ -632,6 +632,48 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
     }
   })
 
+  it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
+    const ctx = await setup()
+    const args = { command: 'printf "[exit code: 5]"', description: 'print' }
+    // A successful command can print text that looks like a marker. renderResult
+    // for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
+    // own tail is `[exit code: 5]`. The parse requires a LEADING newline before
+    // the marker (renderResult always inserts one before a REAL marker), so this
+    // no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
+    const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
+    expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 })
+    // Same for a fake signal marker with no leading newline.
+    const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
+    expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 })
+  })
+
+  it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => {
+    const ctx = await setup()
+    // The background start returns a task-id ack, not a streamed run — no terminal.
+    const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
+    expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
+    expect((call as { terminal?: unknown }).terminal).toBeUndefined()
+    // The ack result is fenced text only — no terminal output / exit pill.
+    const result = ctx.tools.get('bash')!.presentResult!(
+      { command: 'sleep 100', description: 'wait', run_in_background: true },
+      { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
+    )
+    expect(result?.terminal).toBeUndefined()
+    expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }])
+  })
+
+  it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => {
+    const ctx = await setup()
+    // A spawn failure / abort has no process exit — the body is an error message,
+    // not renderResult output, so no terminal output/exit is emitted.
+    const out = ctx.tools.get('bash')!.presentResult!(
+      { command: 'x', description: 'x' },
+      { content: [{ type: 'text', text: 'command aborted' }], isError: true },
+    )
+    expect(out?.terminal).toBeUndefined()
+    expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }])
+  })
+
   it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
     const ctx = await setup()
     const present = ctx.tools.get('bash')!.presentResult!(

Деякі файли не було показано, через те що забагато файлів було змінено