Skip to content

Report what ACP agents actually send: real exit codes, streamed output, args and results - #2211

Closed
SawyerHood wants to merge 1 commit into
ws2b-acp-1-open-wire-enumsfrom
ws2b-acp-2-richer-tool-reporting
Closed

Report what ACP agents actually send: real exit codes, streamed output, args and results#2211
SawyerHood wants to merge 1 commit into
ws2b-acp-1-open-wire-enumsfrom
ws2b-acp-2-richer-tool-reporting

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2198.

What was wrong

The ACP bridge parsed the fields that carry a tool call's real result and then emitted none of them. Findings M1, M2, M3, M5, M6 and M7 of the ACP tool-reporting spike, all confirmed on live wire logs from cursor-agent 2026.08.11 and grok 1.0.5:

  • Exit codes were invented. delta-translation.ts wrote exitCode: status === "failed" ? 1 : 0 and put the whole rawOutput envelope in aggregatedOutput. In the committed acp-cursor/web-search recording Cursor reports {"exitCode":1,"stdout":"","stderr":"[eval]:1 …"} under an ACP status of completed; bb showed exit 0, a successful row, and {"exitCode":1,…} as the output text.
  • A running command's output vanished. grok streams cumulative stdout on in_progress updates. The bridge answered those with suppressedUnhandled + onlyIfNoTurn, so with a turn open they were dropped and with no turn open they became provider/unhandled.
  • rawInput/rawOutput never reached the row. Both agents send them on every call; the grammar has tool.args/tool.result.
  • The permission request's ToolCallUpdate was ignored by the merge cache. Cursor's fetch ask names the URL (Fetch https://nodejs.org/dist/index.json) under a different tool-call id (web_fetch_0) than the call, and its content text ("Not in allowlist: node") is the only statement of why it asks.
  • Relative paths leaked. ACP says locations[].path is absolute; grok sends README.md. grok also names its read argument target_file, which was not in the path-field list.
  • grok's rows opened twice. grok's tool_call carries no kind (it rides _meta["x.ai/tool"]), so a command opened as a generic tool and closed as a second commandExecution item.

What changed

plugins/provider-acp, no grammar change and no daemon wire change.

  • M1 extractAcpCommandResult reads Cursor's exitCode/stdout/stderr and grok's exit_code/output_for_prompt/signal/timed_out; a timeout or terminating signal is noted after the output. An agent that reports no exit code leaves the row without one instead of a fabricated 0.
  • M2 an in_progress update on a row that opened as a command emits command.outputSnapshot; the assembler diffs consecutive snapshots, so grok's cumulative stdout with tail-window resets lands as output deltas.
  • M3 rawInputtool.args, rawOutputtool.result, and the output text → tool.error on a failed call. The inline-image data-URL scrub now runs over every string in the payload, and a payload past 64 KiB is replaced by its rendered text with the same truncation marker the server writes.
  • M5 notePermissionToolCall merges the ask into the call it describes: same id, else the single in-flight call of its kind. The merge is additive and never re-shapes an open row — a row that opens as one kind and settles as another is two rows in the timeline, so a generic row takes the ask's headline instead of its shape. A call that already has a core shape is left alone (ACP file-write approvals present as command approvals carrying a bare directory path #1719). The ask's content text becomes the approval's reason and the banner's presentation.detail.
  • M6 locations[].path, diff paths and rawInput paths resolve against the session cwd; target_file joins path/filePath/file_path. The session cwd stays off the command item — ACP never says where a command ran.
  • M7 the row is named by the programmatic tool name: the protocol's unstable name, else a new per-agent dialect (src/dialect.ts), keyed by the launch executable, that reads _meta["x.ai/tool"] for grok. The dialect also supplies the kind at open, so grok's command opens as a command.

src/dialect.ts is the seed of layer 3's dialect module: vendor side channels are read there, never in the shared wire schema.

How I verified

Live ACP probe, re-run today against both installed agents (the spike's 120-line client; opencode/gemini/hermes are not installed on this machine). Same log replayed through main's translator and this branch's:

grok agent stdio, prompt "Run ls, then read README.md":

BEFORE (main)                                    AFTER
toolCall  tool:"tool"        (opens generic)     commandExecution  command:"ls"
toolCall  result:"README.md" (closes generic)      aggregatedOutput:"README.md\n"  exitCode:0
commandExecution  aggregatedOutput:"README.md\n"   ← one row, not two
  exitCode:0                 ← a SECOND row
toolCall  tool:"tool"  (read_file, generic)      fileRead  path:"/tmp/acp-probe-ws/README.md"
fileRead  path:"README.md"   ← relative + 2nd row  ← absolute, one row

cursor-agent acp, same prompt — the command's output was the rawOutput blob, now it is the real stdout:

BEFORE  aggregatedOutput:"{\"exitCode\":0,\"stdout\":\"README.md\\n\",\"stderr\":\"\"}"
AFTER   aggregatedOutput:"README.md\n"   exitCode:0
        toolCall(read)  arguments:{}  result:{"content":"# ACP probe workspace…"}   ← was dropped

M2 against production traffic. The 83 provider/unhandled rows the spike found (all grok tool_call_update in_progress with cumulative stdout, exported read-only) replayed with their command row open:

BEFORE (main)  83 updates → 0 output events,       0 streamed characters
AFTER          83 updates → 83 outputDelta events, 207,440 streamed characters

G11 — honest result: the 83 prod rows do NOT drop. All 83 arrived after a provider/error had already settled that turn (thread thr_zvyf9ypnv6, sequences 3436-3518, turn/completed at 3435), so there is no row for them to land on and provider/unhandled remains the correct record. What changes is the in-turn case above, where the same updates used to be dropped silently. Total ACP provider/unhandled in the production DB is 155 rows across all 25 grok threads (83 tool_call_update + 37 agent_message_chunk + 35 agent_thought_chunk), all post-turn. The G11 gate proper — unhandled/grammarDrops in recordings/row-counts.json — is unchanged: every acp-cursor cell stays at unhandled: 0, turn-tools stays at 1 grammar drop.

Conformance. pnpm exec turbo run typecheck test --filter=bb-plugin-provider-acp: 16 files, 221 tests. Scripted conformance (12 rules) and recorded conformance (8 acp-cursor cells) both pass.

A2 parity. pnpm --filter @bb/provider-parity rerecord --provider acp-cursor re-wrote the 8 changed bridge→runtime.current.ndjson lanes; UPDATE_PARITY_ROW_COUNTS=1 re-pinned nothing (event and row counts are unchanged in every cell). pnpm exec turbo run test --filter=@bb/provider-parity --force: 43/43.

Two-checkout A2 against a main worktree:

provider result
acp-cursor 10 passed, 0 failed, 1 skipped (27 new allowlist entries, all #2211)
codex 16 passed, 0 failed, 1 skipped — zero new diffs
claude-code 13 passed, 0 failed, 1 skipped — zero new diffs

The allowlisted classes are exactly the intended ones: item/arguments (M3), item/result (M3), item/aggregatedOutput (M1), item/exitCode (M1), and their row projections including one children/*/status where the exitCode: 1 command now projects as an error row. The permission-merge headline change ("Web Fetch""Fetch https://nodejs.org/dist/index.json", "MCP: tool""bb-bridge-AskUserQuestion: AskUserQuestion") needs no entry of its own: #2179's /*/item/presentation entry already covers it for those cells.

turbo.json gains passThroughEnv: ["UPDATE_PARITY_ROW_COUNTS", "UPDATE_TRANSCRIPT_EXPECTATIONS"] on test. Both are documented re-pin knobs and turbo's strict env was stripping them, so the documented command silently did nothing.

Known limit

Cursor's fetch call cannot become a semantic webFetch item: the URL only exists in the permission ask, which arrives after the row is open, and adopting a new shape mid-flight projects as two rows. The row keeps its fetch tool kind and shows the URL as its headline. Giving it the real item kind needs either a deferred open or a projection that folds a re-opened item id — out of scope here.

AGENT GENERATED: by Claude Opus 5

…t, args and results

The ACP bridge parsed rawInput/rawOutput and then emitted neither, synthesized
every exit code from the ACP status, suppressed the in-progress updates that
carry a running command's output, and never used the permission request's
ToolCallUpdate. Spike findings M1, M2, M3, M5, M6, M7.

- M1 the exit code and output are the agent's own (Cursor exitCode/stdout/
  stderr, grok exit_code/output_for_prompt/signal/timed_out); a call whose
  agent reported no exit code now closes without one.
- M2 an in-progress update on a running command emits command.outputSnapshot;
  grok's cumulative stdout with tail resets is the snapshot contract.
- M3 rawInput -> tool.args, rawOutput -> tool.result, the failure text ->
  tool.error, with the data-URL scrub kept and a 64 KiB payload cap.
- M5 the permission request merges into the call it describes (by id, else
  the single in-flight call of its kind); its content text is the approval's
  reason and detail. The row never changes shape mid-flight.
- M6 relative locations/rawInput paths resolve against the session cwd, and
  target_file joins the path fields.
- M7 the programmatic tool name (unstable name, else a per-agent dialect)
  names the row, and the dialect supplies grok's kind at open so its command
  rows no longer open generic and close as a second item.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant