Skip to content

[stacked on #2164] WS1b-claude: Claude Code bridge to grammar v3 with presentation - #2178

Merged
SawyerHood merged 6 commits into
bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87from
bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua
Aug 21, 2026
Merged

[stacked on #2164] WS1b-claude: Claude Code bridge to grammar v3 with presentation#2178
SawyerHood merged 6 commits into
bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87from
bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2164 (WS1b-codex, bb/ws1b-codex-codex-bridge-to-v3-stack-on-2136-thr_xnid5ftd87), which sits on WS2a #2148 → the corpus harness #2121 → the recordings harness #2153 → WS1a #2136 → the contract #2124 (SDK 0.4.11 at the bottom). WS1b-claude of the provider-plugin migration: the Claude Code bridge speaks grammar v3 with a presentation on every item, Claude's tool-name knowledge moves out of core thread-view into the bridge, and the bridge's last Claude-specific result structure is deleted.

Do not merge. Coordinator reviews, Sawyer merges the stack.

Stacked on codex rather than WS1a on purpose: codex added the minimal thread-view projection of delegation items (row + nested child content) that Claude's Agent/Task sub-agents need. The recordings + parity harness beneath this PR is the real #2153 (in the chain, with pnpm rerecord and the PARITY_INITIALIZE_ID fix); the one harness file this PR touches is redact.mjs (bare-message ndjson, dash-encoded home paths). The corpus harness #2121 is in the chain too; the A4 run below is native to this tree.

What was wrong

The Claude bridge emitted v2-shaped items: no presentation (core thread-view kept Claude's tool-name tables — Read/Grep/Glob intents, the Agent/Task delegation row, the TodoWrite/Task* suppression list and todo reducer), Read as an opaque tool item (the top generic tool in the production corpus: 7,568 calls), Agent as a tool item named Agent, TodoWrite/TaskCreate/TaskUpdate as tool rows a core reducer had to understand, bb-injected tools as mcp__bb-bridge__<name> without server, and a structured task-tool result that core parsed — and which never matched in production, because the persisted result was the SDK's friendly string, so the task banner did not work for the Task tools.

What changed (one commit per layer; the last deletes)

  1. Transcript → SDK-stream converter and fixtures (the spec's first task). scripts/provider-recordings/convert-claude-transcript.mjs turns a ~/.claude/projects session (plus its <session>/subagents/agent-*.jsonl sidechains, interleaved by timestamp with parent_tool_use_id from the subagent's toolUseId) into the SDK stream the bridge would have seen. A transcript has no result, no system/init and no task_* family, so the converter synthesizes them deterministically: a result per segment (a root message that stops with a non-tool-use reason, the next prompt, EOF), task_started/task_updated/task_notification for Agent calls from the call, its result (async_launched ⇒ backgrounded) and the <task-notification> resume, api_retry/model-fallback/compact_boundary from the system records. Human prompts only delimit turns — a live stream never echoes them (verified against every committed recording); CLI-injected user messages (isMeta context, task notifications) do stream and are kept. convert-claude-transcripts-sample.sh rebuilds the committed sample: 12 sessions/windows, 1,076 messages (331 sidechain records), 1.85 MB after redaction, all from the owner's own corpus threads: plan mode + AskUserQuestion + ExitPlanMode, WebSearch, WebFetch + Read, Edit/Write, foreground and backgrounded Agents with their sidechains, TaskCreate/TaskUpdate + model_refusal_fallback, Workflow + Monitor + TaskStop, TaskOutput, ScheduleWakeup, SendMessage, api_retry, compact_boundary, mcp__bb-bridge__ tools. Grep, Glob, TodoWrite, MultiEdit and NotebookEdit appear in none of the 2,559 local transcripts; those paths are covered by scripted unit tests. transcript-fixtures.test.ts drives each fixture through the sdk/message envelope into a real assembler and checks structural invariants (every tool_use opens an item its tool_result settles, sidechain items nest under the spawning call, every turn settles, nothing left open, every started item presented) plus a pinned projection per fixture in expected.json (item kinds, tool names, plan snapshots, provider/unhandled, which may only go down).
  2. Presentation on every item; the v3 kindsplugins/provider-claude-code/src/presentation.ts is the one place Claude's tool-name knowledge lives; tool-classification.ts maps every tool_use to its shape with that presentation. ReadfileRead; Grepsearch{content}, Globsearch{path}; Edit/Write/MultiEdit/NotebookEditfileChange with per-verb labels; Bashcommand (a backgrounded call is labelled as a launch); WebSearch/WebFetch as before with presentation. Agent/Taskdelegation (childRef = the call id, which is how the SDK identifies the sub-agent's stream: parent_tool_use_id; background: true for run_in_background, settling at the launch ack on the thread-scoped family; summary = the result text without Claude's agentId:/<usage> lines; sub-agent type and model in the presentation detail). TodoWrite → a collapsed call row plus a settled planSteps snapshot from its arguments; TaskCreate/TaskUpdate/TaskList/TaskGet → a collapsed call row plus a planSteps snapshot of the thread's folded task list (plan-fold.ts, reading the SDK's envelope-level tool_use_result) after each successful call — channel-keyed close deltas, the latest superseding, the same shape as codex update_plan. ToolSearch, TaskOutput, Monitor, ScheduleWakeup, SendMessage, AskUserQuestion, TodoRead, BashOutputtool with presentation.suppress; plan mode, Workflow, TaskStop, Skill, StructuredOutput, worktrees, ListAgents → tool with their own labels/glyphs/titles; an unknown tool reads Running <tool>/Ran <tool>. mcp__<server>__<tool> splits into { server, tool }. The compaction item and every close-without-open fallback carry one too; the close re-states the open's. Thread-view keeps the new kinds rendering until the presentation-driven projection lands (the same minimal bridge codex added for delegation): fileRead/search items project to the tool row with the intents the legacy Read/Grep/Glob calls produced (tested equal to the legacy rows); a tool call whose presentation says suppress is hidden like the legacy name list (failures still render); the todo banner reads a planSteps snapshot as-is. No persisted-event projection changed: the legacy tables stay for old rows (G1 unchanged).
  3. Background tasks carry their presentation — workflows, backgrounded shells and backgrounded sub-agents stay the core backgroundTask kind (genericity rule) and say how they read on open/close. Model fallback and /clear stay the core events they are.
  4. bb-injected tools (Q31) — a mcp__bb-bridge__<name> call is { server: "bb", tool: <bare name> } with the presentation the server resolved onto the DynamicTool definition, learned through configureInjectedTools at session construction; a definition without one presents generically under bb's glyph. The server's statusLabels enrichment skips items with a server, so nothing relies on it.
  5. Delete the Claude v2 translation path — the task tools' structured result (the shape core's legacy todo reducer read) is gone; the tool row carries the text result like every tool and planSteps is the one structured form core sees. The bridge no longer imports the SDK's claude task-tool schemas. Kept on purpose: the bridge's knowledge of its own tool names, and the close-without-open fallback.

HOST_DAEMON_PROTOCOL_VERSION stays 150: nothing on the server↔daemon wire changed (translation and an item-shape change inside the thread/delta lane only).

Regression oracle

All turbo invocations with --concurrency 4; perf suite ignored (known-noisy, flagged on #2121). Old leg: an origin/main worktree at f6fb434ab.

  • Conformance: claude scripted suite + recorded conformance over all 14 recorded cells (incl. auth-failure, plan-mode, subagent, user-question) green. The claude bridge→runtime.current.ndjson lanes are re-recorded with this bridge (pnpm rerecord --plan-with <main checkout> --provider claude-code); the recordings themselves are untouched and the self-suite's row-count pins are unchanged (43/43).
  • Parity (A2), pnpm parity --old <origin/main worktree> --new . --provider claude-code: 13 passed, 0 failed, 1 skipped (process-scoped model-list). Event and row counts equal in every cell. recordings/parity-allowlist.json gains 27 entries naming #2178, three classes:
    • presentation on items — /*/item/presentation, events, 9 cells (approval-allow, approval-deny, compaction, plan-mode, steer, subagent, turn-tools, user-question, web-search; the other 4 cells have no items).
    • Read → fileRead — plan-mode events /2, /4, /24, /25; rows /0/children/{0,5}/{toolName, toolArgs, output, activityIntents/0/command, activityIntents/0/name} (the row projects from a fileRead item: no tool name or arguments, the file contents are not row data, the intent command is Read <path>).
    • Agent → delegation — subagent events /6, /13; rows /0/children/0/toolName ("delegation") and /0/children/0/subagentType (the delegation item has no such field; the type rides the presentation detail).
      Zero unlisted diffs, zero stale entries. codex 16/16 and acp-cursor 10/10 replay against main with zero new diffs (no entries added; nothing of theirs touched). The expected "suppressed low-value rows" and "planSteps" classes produce no parity diff: no recorded cell calls Monitor/TaskOutput/ScheduleWakeup/SendMessage or TodoWrite/Task*, and ToolSearch/AskUserQuestion were already hidden by name.
  • Corpus (A4), 307 threads / 93,262 rows: zero diffs, claude-code and codex alike, no corpus allowlist entry needed. Persisted Claude events are toolCall items without presentation, and every thread-view change here applies only to the v3 kinds and to presentation.suppress — the legacy tables and reducer are untouched — so old rows project identically by construction.
  • G11: provider/unhandled flat on every claude cell (0→0; compaction and steer 2→2) and on the corpus; pinned per transcript fixture (23 across 12 fixtures, every one a CLI-injected string-content user message — compaction summaries, <task-notification> resumes — which visibility classifies unknown today; lowering that is a separate change).
  • G1: unchanged (no provider-id literal added or removed in core; the legacy Claude tool-name tables stay for persisted rows).

Tests (forced): thread-view 385, claude-code 322, codex 193, provider-bridge-protocol 218, provider-parity 43; server 1832/1833 (the one failure is the known local umask case in internal-skill-trees, which passes in CI). Typecheck green for the claude plugin, thread-view, server and agent-runtime on the re-stacked base.

Not done

  • Live QA cells via bb-dev-app (turn/steer/stop/approve/deny/question/subagent/resume/fork/plan with screenshots) — not run; the recorded cells, the transcript fixtures and parity were the oracle.
  • planSteps rows are not projected as timeline rows (status quo: TodoWrite/Task* rows were hidden); the banner reads them. The presentation-driven projection workstream owns the rows.
  • The delegation row loses the "(Explore)" sub-agent-type suffix for new Claude sub-agent rows until the projection reads the presentation detail (allowlisted; old rows unaffected).
  • Backgrounded Agent calls keep today's two-row structure (the delegation settles at the launch ack; the local_agent background task tracks the work). Folding the task into a single background delegation is a larger change to the runtime's open-work tracking and the background-commands card.
  • String-content sdk/user messages (task-notification resumes, compaction summaries) still surface as provider/unhandled; the fixtures show they are the whole G11 residue for Claude. A one-line visibility change would lower it but changes parity for the steer/compaction cells, so it is left for a deliberate follow-up.

AGENT GENERATED: by Claude Opus 5

@SawyerHood
SawyerHood force-pushed the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch from 8766c32 to 2aa65d1 Compare August 21, 2026 09:48
@SawyerHood
SawyerHood force-pushed the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch from 2aa65d1 to 9d05317 Compare August 21, 2026 10:03
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…ridge lanes

The claude-code recordings' bridge→runtime.current.ndjson lanes are what
this checkout's bridge emits (pnpm rerecord --plan-with <main checkout>);
the recordings themselves are untouched and the self-suite's row-count pins
are unchanged. recordings/parity-allowlist.json names every intended
byte-inequivalence against main with #2178 and a reason: presentation on
items (9 cells), Read → fileRead (plan-mode: 4 events, the two Read rows'
tool name, arguments, output and intent rendering), Agent → delegation
(subagent: 2 events, the row's tool name and sub-agent type). Event and row
counts are equal in every cell; provider/unhandled is flat.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch from e235d67 to a3f8d7d Compare August 21, 2026 10:17
@SawyerHood
SawyerHood marked this pull request as ready for review August 21, 2026 10:20
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Coordinator review — APPROVE (SlopCop disabled; this review is the gate).

Oracle — as clean as codex's: claude parity 13/13 with 27 allowlist entries in three named classes, zero unlisted, zero stale; codex 16/16 and acp 10/10 with zero new diffs (you did not touch them); A4 zero row diffs across all 307 production threads; G11 flat on every cell. Recorded conformance green for all 14 claude cells; self-suite 43/43 with row-count pins unchanged.

On the zero corpus diffs — you did this better than I specified. I told you to expect claude row diffs and allowlist them. You got zero, and I verified why rather than accept it: your thread-view changes are additive — suppression is presentation?.suppress === true OR the legacy name table, and todo-snapshot-extraction.ts keeps the TodoWrite-by-name path beside a new planSteps branch. So persisted pre-presentation rows render identically and the new code fires only on v3 kinds. That is additive-then-delete honored to the letter (WS3 deletes the name tables). Correct, and stronger than my spec.

Fixtures: 12 redacted transcripts (1,076 messages, 1.85 MB) converted from Sawyer's own corpus sessions, with a manifest naming every source and a pinned projection per fixture. I swept the 12 new files independently: zero survivors — my one scare was my own unanchored sk- grep matching the sk- in task-notification; redact.mjs correctly uses a lookbehind + 20-char minimum and is the better tool. The three pre-existing fixtures that match are synthetic /Users/developer/… placeholders from before this PR.

Wire: HOST_DAEMON stays 150 — correct, translation-only, no daemon change.

Honest gaps noted (not this PR's debt): live QA cells; planSteps rows (banner only → WS3); the "(Explore)" delegation suffix until presentation detail renders (WS3); the string-content sdk/user G11 residue (deliberate follow-up). redact.mjs edits → upstream to #2153 (I'll route).

Lands after #2164 codex (rebase onto its vendor-dropped head first). Sawyer merges; I do not.

AGENT GENERATED: by Claude Opus 4.8

SawyerHood added a commit that referenced this pull request Aug 21, 2026
…ridge lanes

The claude-code recordings' bridge→runtime.current.ndjson lanes are what
this checkout's bridge emits (pnpm rerecord --plan-with <main checkout>);
the recordings themselves are untouched and the self-suite's row-count pins
are unchanged. recordings/parity-allowlist.json names every intended
byte-inequivalence against main with #2178 and a reason: presentation on
items (9 cells), Read → fileRead (plan-mode: 4 events, the two Read rows'
tool name, arguments, output and intent rendering), Agent → delegation
(subagent: 2 events, the row's tool name and sub-agent type). Event and row
counts are equal in every cell; provider/unhandled is flat.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch from a3f8d7d to e6387bd Compare August 21, 2026 10:36
SawyerHood and others added 6 commits August 21, 2026 10:50
…ator fixtures

A Claude Code transcript is a persisted conversation (user/assistant records
with tool_use and tool_result blocks, a few system notices, bookkeeping rows),
not the streaming SDK message shapes the claude-code bridge consumes: it has
no `result`, no `system/init`, no `system/task_*` family, and its subagents
live in `<session>/subagents/agent-*.jsonl` sidechains.

`scripts/provider-recordings/convert-claude-transcript.mjs` turns one session
(plus its sidechains, interleaved by timestamp with `parent_tool_use_id` from
the subagent's `toolUseId`) into the SDK stream the bridge would have seen:
a `result` per segment (a root message that stops with a non-tool-use reason,
the next prompt, EOF), `task_started` / `task_updated` / `task_notification`
for Agent calls from the call, its result (`async_launched` ⇒ backgrounded)
and the `<task-notification>` resume, `api_retry` / model-fallback /
`compact_boundary` from the system records, and a `system/init` from the
first record. Human prompts only delimit turns — a live stream never echoes
them; CLI-injected user messages (isMeta context, task notifications) do
stream and are kept. Every synthesized message is deterministic. `--turns A-B`
slices a session by human-prompt turn.

`convert-claude-transcripts-sample.sh` rebuilds the committed sample: 12
sessions / windows from the owner's own corpus threads (plan mode +
AskUserQuestion, ExitPlanMode, WebSearch, WebFetch + Read, Edit/Write,
foreground and backgrounded Agents with their sidechains, TaskCreate/
TaskUpdate + model_refusal_fallback, Workflow + Monitor + TaskStop,
TaskOutput, ScheduleWakeup, SendMessage, api_retry, compact_boundary,
mcp__bb-bridge__ tools), redacted with `redact.mjs` — which now also
handles bare-message ndjson and the dash-encoded home directory Claude
uses for its project paths — 1.85 MB, 1,081 messages. Grep, Glob, TodoWrite,
MultiEdit and NotebookEdit appear in none of the 2,559 local transcripts;
those paths stay covered by the scripted unit tests.

`transcript-fixtures.test.ts` drives each fixture through the sdk/message
envelope into a real assembler and checks the structural invariants (each
tool_use opens an item its tool_result settles, sidechain items nest under
the spawning call, every turn settles, nothing is left open) plus a pinned
projection per fixture (item kinds, tool names, presentation coverage,
provider/unhandled — which may only go down) in `expected.json`. The pins
are the regression oracle the translation layers update deliberately.

Co-Authored-By: Claude <noreply@anthropic.com>
…o map to the v3 kinds

Grammar v3 for the Claude Code bridge. `presentation.ts` is the one place
Claude's tool-name knowledge lives (which built-in is a command, a read, a
search, an edit, a sub-agent, a plan update, a low-value housekeeping call,
and how each reads as a row); `tool-classification.ts` maps every tool_use
to its item shape with that presentation, and `plan-fold.ts` folds the
task-list tools into plan snapshots. Core keeps no new table.

- Read → `fileRead` (the top generic tool in the corpus, 7,568 calls);
  Grep → `search{content}`, Glob → `search{path}`; Edit/Write/MultiEdit/
  NotebookEdit → `fileChange` with per-verb labels; Bash → `command`
  (a backgrounded call is labelled as a launch); WebSearch/WebFetch as
  before, now with presentation. A file read, a search and a delegation
  carry no output on the canonical item.
- Agent/Task → foreground `delegation` (`childRef` = the call id, which is
  how the SDK streams the sub-agent's messages: `parent_tool_use_id`);
  `background: true` for `run_in_background`, settling at the launch ack on
  the thread-scoped family; the child's summary is the result text without
  Claude's `agentId:`/`<usage>` metadata lines; the sub-agent type and
  requested model ride the presentation detail.
- TodoWrite → a collapsed call row plus a settled `planSteps` snapshot from
  its arguments; TaskCreate/TaskUpdate/TaskList/TaskGet → a collapsed call
  row plus a `planSteps` snapshot of the thread's folded task list after
  each successful call, read from the SDK's envelope-level
  `tool_use_result` (the persisted string result never parsed, so the task
  banner had not worked for these in production). Channel-keyed close
  deltas, the latest snapshot superseding — the same shape as codex
  update_plan.
- ToolSearch, TaskOutput, Monitor, ScheduleWakeup, SendMessage,
  AskUserQuestion, TodoRead, BashOutput → `tool` with
  `presentation.suppress`; plan mode, Workflow, TaskStop, Skill,
  StructuredOutput, worktrees, ListAgents and the rest → `tool` with their
  own labels, glyphs and argument-derived titles; an unknown tool reads
  `Running <tool>` / `Ran <tool>`.
- `mcp__<server>__<tool>` splits into `{ server, tool }` (bb's own server
  gets its definition-driven presentation in the next layer).
- The compaction item and every close-without-open fallback carry a
  presentation too; the close re-states the open's.

Thread-view keeps the new kinds rendering until the presentation-driven
projection lands (the same minimal bridge codex added for `delegation`):
`fileRead` and `search` items project to the tool row with the intents the
legacy Read/Grep/Glob calls produced; a tool call whose presentation says
`suppress` is hidden like the legacy name list (failures still render);
the todo banner reads a `planSteps` snapshot as-is. No persisted-event
projection changes: the legacy tables stay for old rows.

Pins: the scripted suites move to the v3 shapes; the transcript fixtures
pin fileRead/delegation/plan-snapshot counts and an every-started-item-
is-presented invariant per session.

Co-Authored-By: Claude <noreply@anthropic.com>
The SDK task family — dynamic workflows (the Workflow tool), backgrounded
shell commands and backgrounded sub-agents — stays the core `backgroundTask`
kind (the genericity rule: background work is core), and its item.open and
item.close now say how the row reads: "Running workflow" / "Workflow
finished" titled by the workflow name, "Running background command" /
"Background command finished", "Running background subagent" / "Background
subagent finished", each titled by the task description. Progress deltas
carry no presentation by schema; the close re-states the open's. Model
fallback and `/clear` stay the core events they are.

With this every item the Claude bridge opens carries a presentation; the
transcript fixtures now pin that invariant for background tasks too.

Co-Authored-By: Claude <noreply@anthropic.com>
…'s presentation

Q31 for the Claude bridge. Claude names the tools bb injects through the
bridge's MCP server `mcp__bb-bridge__<name>`; the item now reads
`{ server: "bb", tool: <bare name> }`, and its presentation is the one the
server resolved onto the `DynamicTool` definition at session construction
(`experimental_presentation` → status labels → generic), which the bridge
learns through `configureInjectedTools`. A definition without one (a server
from before the field existed) presents generically under bb's glyph, and a
`bb-bridge` call the session was not constructed with still names bb as its
origin. The server's `statusLabels` enrichment skips items that carry a
server, so the row no longer relies on it.

The `bb-bridge` server name moves to the classification module; the MCP
proxy reads it from there.

Co-Authored-By: Claude <noreply@anthropic.com>
…ults

The last Claude-specific result structure core had to interpret is gone:
TaskCreate/TaskUpdate/TaskList/TaskGet results no longer parse into a
structured `result` on the tool item (the shape thread-view's legacy todo
reducer read, and which never matched in production because the persisted
result was the SDK's friendly string). The tool row now carries the text
result like every other tool; the plan fold reads the envelope-level
`tool_use_result` (or JSON text when there is none) and the `planSteps`
snapshot is the one structured form core sees. The claude task-tool
schemas the bridge imported from the SDK for it are no longer referenced
there; the legacy reducer in thread-view keeps reading persisted rows
until the projection workstream removes it.

What stays is the point, not a remnant: the bridge's knowledge of its own
tool names lives in `presentation.ts`/`tool-classification.ts`, and the
close-without-open fallback still needs a terminal shape for a result whose
call it never saw.

Co-Authored-By: Claude <noreply@anthropic.com>
…ridge lanes

The claude-code recordings' bridge→runtime.current.ndjson lanes are what
this checkout's bridge emits (pnpm rerecord --plan-with <main checkout>);
the recordings themselves are untouched and the self-suite's row-count pins
are unchanged. recordings/parity-allowlist.json names every intended
byte-inequivalence against main with #2178 and a reason: presentation on
items (9 cells), Read → fileRead (plan-mode: 4 events, the two Read rows'
tool name, arguments, output and intent rendering), Agent → delegation
(subagent: 2 events, the row's tool name and sub-agent type). Event and row
counts are equal in every cell; provider/unhandled is flat.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch from e6387bd to 61a0a96 Compare August 21, 2026 10:53
@SawyerHood
SawyerHood merged commit 7bc46ad into main Aug 21, 2026
13 checks passed
@SawyerHood
SawyerHood deleted the bb/ws1b-claude-claude-code-bridge-to-v3-stack-on-21-thr_c7uz7y2hua branch August 21, 2026 14:20
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…rpus row snapshots, timeline perf, permission matrix (#2121)

> **Stacked in the chain**: contract #2124 → WS1a #2136 → recordings
#2153 → WS2a #2148 → **this PR** → codex #2164 → claude #2178 → acp.
Base `bb/ws2a-registry-providerinfo-stack-on-2124-thr_hbi4kggyzb`. WS1a
deleted the `ProviderAdapter` interface and the fake adapter, so the
runtime permission matrix drives `handleRuntimeProviderRequest` through
the real bridge-protocol adapter built for the scripted echo launch
(`createProviderForId` + `createScriptedEchoLaunch`), with the
initialize handshake setting `approvalEnforcedBy` and a canonical
`interaction/request` on the wire. The 80 outcomes pinned on `main` are
unchanged; the `tool_use` subject the v3 contract added gets 20 measured
cells of its own (the type-level union guard demanded them).
`resolvePermissionEscalation` now takes only the initiator, so the
server table is 3 initiator cells + the 54 policy-shape cells. The row
snapshots minted on `main` compare byte-identical (307/307, 0 diffs)
under WS1a, #2153, and WS2a. Pre-stack history: `4af0367`.

## What was wrong

The provider-plugin migration abandons byte-equivalence with the old
goldens on purpose, which removes the regression oracle. Before any
provider code moves, `main` needs machine checks on real data that every
later layer can run: projected rows for the 307 production threads in
the private corpus (A4), a timeline-build and event-size baseline, and
the permission-decision matrix (A5/G12) pinned as a literal table before
WS5 changes the unions. None of these existed.

## What changed

No production code changes. Tests, test helpers, one script, one turbo
task, docs.

- **Corpus reader** — `packages/test-helpers/src/provider-corpus.ts`:
`corpusAvailable()`, `listCorpusThreads({ provider?, reasons? })`,
`loadCorpusThread(id)`. Reads `BB_PROVIDER_CORPUS_DIR` (`manifest.json`,
`threads/<provider>/<id>/{meta.json,events.ndjson}`), validates rows at
the boundary with zod (ids must be one safe path segment), resolves each
thread through its manifest entry and fails unless `meta.json` and every
event row agree on id, provider, reasons, and row count, and decodes
event payloads with `@bb/domain` `parseStoredThreadEvent` +
`buildThreadEventRow` — the same path as the server's
`parseStoredEventRow`. `.gitignore` gets `**/provider-corpus/**` with
re-includes for the two in-repo directories of that name.
- **Row snapshots** —
`apps/server/test/provider-corpus/row-snapshots.test.ts` +
`corpus-harness.ts`. Each thread is inserted into in-memory SQLite with
its original ids, sequences, and timestamps (raw `INSERT` so nothing is
minted), then every timeline page is built via
`buildThreadTimelineWithProfile` with the options the route uses — event
budget from `defaultFeatureFlags`, inline-output limit from
`DEFAULT_MAX_INLINE_OUTPUT_CHARS`, display name and plan command from
the real provider registry (`createTestProviderRegistry()` loads the
first-party plugin declarations) through `resolveProviderPlanCommand`,
unhandled ops included, output truncation + preview — following the
route's own `olderCursor`. Two variants per thread: `default` (turn rows
summarized) and `nested` (`includeNestedRows`, children materialized).
Snapshots go to
`$BB_PROVIDER_CORPUS_DIR/snapshots/rows/<provider>/<threadId>.json`,
keys sorted. Nothing is blanked: no wall-clock value reaches the rows,
and write mode proves it by projecting every thread twice and requiring
byte equality. Compare mode fails on any diff not covered by
`snapshots/allowlist.json` (`threadId` | `provider` | `"*"` scope,
JSON-pointer or `*`/`**` glob path, `pr`, `reason`), prints a unified
diff for the first 3 differing threads plus a count, lists the entries
it used, and fails on entries that cover nothing.
- **Perf baselines** — `timeline-perf.test.ts`: 10 largest threads per
provider, latest page and full page walk, 5 profiled builds each after a
warm-up, stage p50s from `ThreadTimelineBuildProfile`, persisted `data`
bytes median/p95/total, rows produced. Written to
`snapshots/perf-baseline.json`; compare fails at baseline × 1.10 for
build cost and × 1.15 for median event size. **Deviation from the brief,
with data:** the gate uses a normalized cost — min build time ÷ min time
of a fixed CPU workload that shares no code with the timeline (JSON
codec + sort over a deterministic document, run once per sample right
before the builds) — not raw p50/p95. Raw p50 of the same commit swung
up to 30% between two back-to-back runs on this 16-core box at load ~6
(14 of 20 threads tripped a literal 1.10 gate on the very next run);
each side's minimum discards its own contended samples, interleaving
keeps both minima in one short window, and a workload outside the
timeline path means a uniform regression still moves the ratio. Raw
p50/p95 are still recorded and printed. Up to 3 attempts per thread
(write mode keeps the median attempt; compare stops at the first pass),
a 5 ms floor for tiny latest-page builds, and compare mode refuses a
baseline written with different gate settings. The table header reports
the load average and flags an oversubscribed machine.
- **CI micro-benchmark** — same file, no corpus: `synthetic-thread.ts`
builds a 10,019-event thread (every item kind, deltas, background tasks,
usage events) and walks all 12 pages. Gate: minimum of 5 walks under
1,500 ms (local minimum 150–170 ms; the ceiling is ~10× so a slow runner
passes while a quadratic regression still fails).
- **Permission matrix** —
`packages/agent-runtime/src/permission-matrix.test.ts`: the runtime
chokepoint `handleRuntimeProviderRequest` over permission policy (5
members of the discriminated union) × approval subject (5: command,
file_change, permission_grant, plan, tool_use) × `approvalEnforcedBy`
(2) × deny availability (2) = 100 cells, each a literal; `satisfies
Record<CellKey, Outcome>` plus `SameUnion` type guards against the
domain unions (dropping a row or a union member fails `tsc`, verified on
the stack), plus runtime assertions that every local subject kind parses
as a payload. The request goes through WS1a's real bridge-protocol
adapter. `apps/server/test/permissions/permission-matrix.test.ts`:
`resolvePermissionEscalation` over the 3 initiators and the 54-cell
runtime-permission-policy shape cross product (5 accepted), with the
reviewer vocabulary pinned to the policy union at the type level.
- **Seeds** — `scripts/provider-corpus/snapshot-rows.sh
[write|compare]`, `@bb/server#test:provider-corpus` (uncacheable,
`passThroughEnv` for the two variables — strict turbo env mode strips
them from the plain `test` task), and a "Provider corpus" section in
`docs/debugging-and-qa.md`.

### Permission matrix (runtime)

Outcome: `forward` = reaches `onInteractiveRequest` (user decides);
`auto-deny` = runtime answers deny; `encode-error` = runtime wants to
auto-deny but `deny` is not in `availableDecisions`, so the provider
gets a JSON-RPC error. Subject kind (command, file_change,
permission_grant, plan, tool_use) never changes the outcome, so the
table is collapsed over it (each row below is 5 cells).

| policy (mode/escalation) | approvalEnforcedBy | deny available |
outcome |
| --- | --- | --- | --- |
| accept-edits/ask | runtime | yes / no | forward |
| accept-edits/ask | provider | yes / no | forward |
| accept-edits/deny | runtime | yes | **auto-deny** |
| accept-edits/deny | runtime | no | **encode-error** |
| accept-edits/deny | provider | yes / no | forward |
| auto/ask | runtime | yes / no | forward |
| auto/ask | provider | yes / no | forward |
| auto/deny | runtime | yes | **auto-deny** |
| auto/deny | runtime | no | **encode-error** |
| auto/deny | provider | yes / no | forward |
| full/– | runtime | yes / no | forward |
| full/– | provider | yes / no | forward |

Bridge-kit predicate: `shouldAutoDenyInteractiveRequest` → ask: false,
deny: true, null: false.

Server escalation by initiator: user → ask; agent → deny; system → deny.
(On `main` the function also took the thread and was measured
identically for root, delegated-child, and fork threads; WS1a removed
the unused argument.)

Accepted runtime policy shapes (5 of 54):
accept-edits/workspace/user/{ask,deny},
auto/workspace/automatic/{ask,deny}, full/full/–/–.

Observations (pinned, not fixed):

1. Permission **mode** never auto-decides anything in the runtime or the
server. Only escalation does, and escalation is purely a function of the
turn initiator. Mode is translated into provider-native settings (codex
`approvalPolicy`/sandbox, Claude SDK `permissionMode`) and enforced by
the provider. So a runtime-enforced provider in `full` mode that did
send an approval would prompt the user.
2. The `encode-error` cells: on an agent- or system-initiated turn, a
runtime-enforced provider whose approval omits `deny` receives a
JSON-RPC error instead of a decision. Codex forwards the provider's own
decision list, so this is reachable in principle; no first-party bridge
omits deny today.
3. `plan` approvals are treated like any other subject by the runtime:
on a system-initiated turn with a runtime-enforced provider they are
auto-denied. Claude is provider-enforced and always forwards
`ExitPlanMode`, so only codex plan approvals can hit this.

## How you verified

- `pnpm exec turbo run typecheck --filter=@bb/server
--filter=@bb/test-helpers --filter=@bb/agent-runtime` — 6 tasks
successful.
- On the WS1a stack (`--concurrency 4`): `pnpm exec turbo run typecheck
--filter=@bb/agent-runtime --filter=@bb/server
--filter=@bb/test-helpers` green; `pnpm exec turbo run test
--filter=@bb/agent-runtime --filter=@bb/test-helpers` — 31 files, 434
tests passed (102 of them the matrix); `pnpm exec turbo run test
--filter=@bb/server` — 197 files, 1,886 tests passed, the one failure
again the local umask assertion. With the corpus set, the row snapshots
minted on `main` compare byte-identical under WS1a's v3 assembler:
307/307, 0 diffs.
- On `main` before the stack, corpus absent: `pnpm exec turbo run test
--filter=@bb/agent-runtime --filter=@bb/test-helpers` — 32 files, 504
tests passed (82 of them the matrix). `pnpm exec turbo run test
--filter=@bb/server` — 196 files passed, 1 skipped (the row-snapshot
suite), 1,884 tests passed; the single failure is the pre-existing
`internal-skill-trees` file-mode assertion (this checkout's umask 0002
yields 0664 where the test expects 0644; it fails on clean `main` here
and passes in CI). The two corpus suites report as skipped; the
synthetic benchmark runs (`Synthetic 10019-event thread: 12 pages, 1165
rows projected, full walk p50 174 ms`).
- Corpus present: `scripts/provider-corpus/snapshot-rows.sh compare` → 2
files, 328 tests passed (307 row snapshots + 20 perf threads + 1
synthetic).
- Row snapshot write mode: **307 threads, 93,262 rows (top-level +
nested children), 270.5 MB (283,656,293 bytes), 96.9 s wall**; compare
mode on the same commit: 73.1 s, 0 diffs. After switching the harness
from copied provider literals to the real registry, compare was still
307/307 with 0 diffs — the snapshots are byte-identical.
- Perf gate on the final estimator: baseline written at load 9.2,
compare on the same commit at load 5.7 passed 20/20 threads on the first
attempt, ratios 0.80–1.09× of baseline. Every one of the 330,626 corpus
events decodes with today's `parseStoredThreadEvent` (426 MB of `data`).
- Allowlist machinery exercised by mutating one snapshot: compare fails
with the unified diff; an entry covering
`/variants/*/pages/*/rows/*/text` for that thread passes; a stale entry
fails with "every snapshots/allowlist.json entry must cover at least one
diff".
- Exhaustiveness guards exercised: deleting one matrix row → `TS2741
Property '"full/-|plan|provider|deny-unavailable"' is missing`; dropping
`"provider"` from the enforcer list → `SameUnion` becomes `false` and
the `satisfies` rejects the extra keys.

### Perf baseline (write mode, this commit, load 9.2/16 cores; ms; norm
= min build ÷ min `json-sort-v1` calibration)

| thread | provider | events | data bytes p50/p95 | latest rows | latest
p50/p95 ms | latest norm | pages | walk rows | walk p50/p95 ms | walk
norm |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| thr_62bp724ett | claude-code | 10207 | 570/6375 | 18 | 23.3/27 | 0.103
| 8 | 241 | 922.3/1228 | 4.855 |
| thr_zqfgksiw39 | claude-code | 9012 | 365/3587 | 50 | 90.5/119 | 0.388
| 7 | 279 | 532/564.4 | 2.466 |
| thr_zc9yxt8mwk | codex | 8795 | 316/3025 | 10 | 24.9/58.3 | 0.102 | 5
| 45 | 323.1/395.5 | 1.510 |
| thr_j4hfucc5bb | claude-code | 7679 | 331/1285 | 52 | 29.9/35.5 |
0.132 | 8 | 335 | 404.6/472.4 | 1.591 |
| thr_f9jfidwbx8 | claude-code | 6163 | 454/4912 | 26 | 19.5/23.7 |
0.156 | 6 | 132 | 198.7/225.5 | 1.720 |
| thr_w6i678ha85 | claude-code | 5826 | 371/2820 | 19 | 25.7/40 | 0.204
| 3 | 37 | 149.6/172.4 | 1.163 |
| thr_qvau3b2d5b | claude-code | 5685 | 355/3613 | 111 | 36.9/50.4 |
0.298 | 5 | 168 | 154.3/173.4 | 1.311 |
| thr_2pkc3q5imw | codex | 5487 | 316/3642 | 3 | 32.9/36.6 | 0.307 | 4 |
15 | 87/89.5 | 0.808 |
| thr_7wvd28jzpj | claude-code | 5015 | 345/3497 | 36 | 25.8/31.6 |
0.219 | 3 | 94 | 130.7/137.4 | 1.102 |
| thr_nbghhtnurj | codex | 4341 | 316/8824 | 11 | 20.8/27.8 | 0.180 | 4
| 35 | 90.4/94.6 | 0.849 |
| thr_wb9c2q8gm4 | claude-code | 4228 | 378/2469 | 65 | 31.3/36.4 |
0.268 | 3 | 150 | 86.3/89 | 0.801 |
| thr_ksr44swb3q | codex | 4063 | 316/4513 | 10 | 18.7/20.2 | 0.178 | 3
| 23 | 64/67.4 | 0.589 |
| thr_tsfz8rtt72 | codex | 4055 | 316/4571 | 18 | 20.6/22 | 0.185 | 2 |
29 | 71/74.3 | 0.633 |
| thr_qe8y73b26t | claude-code | 3880 | 245/991 | 2 | 2.8/2.9 | 0.026 |
2 | 9 | 54.3/55.5 | 0.520 |
| thr_n3xqyz69nk | claude-code | 3454 | 341/4341 | 42 | 31.6/35.4 |
0.307 | 4 | 59 | 84.8/87.8 | 0.824 |
| thr_nm27yahx34 | codex | 3314 | 174/5030 | 17 | 14.3/17.5 | 0.135 | 3
| 61 | 54.6/56.3 | 0.519 |
| thr_ssix3mhxk5 | codex | 3088 | 316/3703 | 44 | 12.8/15 | 0.122 | 3 |
73 | 50/52.5 | 0.478 |
| thr_72dhrw3s8a | codex | 3067 | 174/1659 | 5 | 35/37.3 | 0.305 | 2 |
14 | 45.6/63.9 | 0.430 |
| thr_u8agadfekn | codex | 2892 | 334/2361 | 5 | 51.2/52.5 | 0.453 | 1 |
5 | 49/49.5 | 0.433 |
| thr_me47xr5c3y | codex | 2466 | 316/4318 | 55 | 21.2/22.1 | 0.202 | 2
| 99 | 35.1/36.5 | 0.347 |

Part of the provider-plugin migration: the Step 0 baselines PR from the
design spec's "Regression confidence" section. No tracking issue.

> AGENT GENERATED: by Claude Code (claude-marshmallow-ht-eap)

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…bridge to grammar v3 with presentation (#2164)

Stacked on #2121 (corpus harness + permission matrix,
`bb/provider-baselines`) ← #2148 (WS2a) ← #2153 (recordings) ← #2136
(WS1a) ← #2124 (contract). WS1b-codex of the provider-plugin migration:
the codex bridge speaks grammar v3 with a presentation on every item,
its natives map to the core kinds, bb-injected tools carry their
presentation, goals and the macOS permission profile are codex extension
kinds, and the codex v2 path is deleted.

**Do not merge.** Coordinator reviews, Sawyer merges the stack.

## Stack

contract #2124 → WS1a #2136 → recordings #2153 (incl. #2177 and the
`PARITY_INITIALIZE_ID` follow-up) → WS2a #2148 → corpus harness #2121 →
**this PR** → WS1b-claude #2178. Six commits: the five codex layers
below plus one `parity:` commit carrying this PR's allowlist entries and
the codex `bridge→runtime.current.ndjson` lanes re-recorded against this
bridge with #2153's `pnpm rerecord --provider codex --plan-with <main
checkout>`. No vendored harness copies remain (they were needed only
while #2153 was outside the stack). The A4 corpus check was run from a
throwaway worktree with #2121's harness cherry-picked on top, since
#2121 is not in the stack.

## What was wrong

The codex bridge emitted v2-shaped items: no presentation (core
thread-view kept codex's tool-name tables), native sub-agents as `tool`
items named `spawnAgent`, `update_plan` as a turn-level event the UI
discards, goals as core `thread/goal/*` events, bb-injected tools
without `server`, and a macOS permission profile on a command approval
failed the whole approval. Open work rode an out-of-band
`thread/openWork` notification.

## What changed (one commit per layer; the last deletes)

1. **Presentation on every item.open/close** —
`plugins/provider-codex/src/presentation.ts` is the one place codex
tool-name knowledge lives: shell commands (wrapper stripped from the
headline), file edits, the bundled `node_repl` server ("Ran JavaScript"
with the call's title), other MCP servers by tool name, dynamic tools,
collab verbs, web search/fetch, image views, reasoning, messages, plans,
compactions, the synthesized sub-agent spawn. An invariant suite drives
one item per codex native through the real translator and asserts every
lifecycle delta carries one.
2. **Delegation + planSteps** — the synthesized native sub-agent is a
foreground `delegation` (`childRef` = agent thread id, `label` =
agentPath); a follow-up to a settled agent re-opens the same item and
the row closes only when the agent owes nothing more (the exact
open-work predicate); a dead app-server child settles its open
delegations as failed on the wire. A collab call that names its receiver
is a delegation to it; a bare `wait` stays a tool item with its collab
presentation. `turn/plan/updated` becomes a settled `planSteps` snapshot
per update. `RuntimeBackgroundWorkState` counts a pending delegation as
open work. **Thread-view projects a `delegation` item to the existing
delegation row with the child content nested** — without this the row
and every child message under it vanish before the projection workstream
lands (the projection suppresses orphans with a `parentToolCallId`).
`planSteps` items are not projected yet (status quo: codex plans were
discarded).
3. **bb-injected tools carry their presentation** (Q31) —
`bb.agents.registerTool({ experimental_presentation })` (+
`docs/api_to_audit.md`); the server resolves one presentation per tool
at its boundary (declaration → status labels → generic label + the
plugin's branding glyph / `Toolbox`) onto `DynamicTool.presentation`;
the codex bridge emits calls to injected tools as `{ server: "bb", tool
}` with that presentation. ask-user-question and workflows declare
theirs (AskUserQuestion and `bb_workflow_result` collapse by default).
`HOST_DAEMON_PROTOCOL_VERSION` 149 → 150 (149 is WS2a's #2148, this PR's
base; the history reads 147 → 148 → 149 → 150). The field is optional
and `dynamicToolSchema` is not strict, so an older daemon strips it and
keeps working; the bump follows the repository rule for a widened
server↔daemon wire. Optional per A1: every committed recording's runtime
lane predates the field and must keep replaying; the stabilization pass
makes it required.
4. **Goals + macOS profile as codex extension kinds** —
`provider-codex/goal` (state; `null` once cleared) and
`provider-codex/macos-permission` (item) declared on the registration
with plugin-owned zod schemas (`extension-kinds.ts`). The bridge emits
goals as extension state. A command approval asking for macOS
capabilities now reaches the user for the command; the profile rides the
timeline as its own row saying bb cannot grant it (the plugin-rendered
approval is WS5's). **Read-time conversion in core:**
`parseStoredThreadEvent` decodes persisted `thread/goal/updated|cleared`
rows into the extension state (the one path every stored-event read
takes); thread-view goal extraction and the runtime's goal-clear wait
read that state. The sidebar's latest-goal query becomes a
latest-thread-state-by-kind query (partial index widened to
`thread/extensionState/updated`, migration **0106** `thread_state_index`
— regenerated with Drizzle so its snapshot chains from WS2a's 0105
`provider_settings_to_plugins`; `json_extract` kind filter). Verified
against the corpus: the 721-row goal thread renders its goal unchanged.
5. **Delete the codex v2 path** — `thread.goal`/`thread.goalCleared`
leave the grammar and assembler (G3 snapshot updated;
`PROVIDER_BRIDGE_PROTOCOL_VERSION` stays 2 under the grammar range, as
WS1a's v2 deletion did); the `thread/openWork` notification leaves the
protocol, adapter and reaper (an unknown notification is ignored); the
bridge drops its open-work reporting and the hard-coded AskUserQuestion
presentation. Kept: the bridge's knowledge of its spawn/resume collab
verbs for a receiver-less call — tool-name knowledge in the bridge is
the point, not a remnant. The `thread/goal/*` domain event types remain
as read-only legacy vocabulary.

Naming note: the spec says `codex/goal`; the extension-kind namespace is
the **plugin id**, which is `provider-codex`, and that is how the
registry resolves the schema.

## Regression oracle

All turbo invocations with `--concurrency 4`; perf suite ignored
(known-noisy, flagged on #2121).

- **Conformance**: codex scripted suite + recorded conformance over all
17 recorded cells (incl. archived-resume, auth-failure, empty-rollout,
missing-rollout) green.
- **Parity (A2)**, `pnpm parity --old <origin/main worktree at
f6fb434> --new . --provider codex`: **16 passed, 0 failed, 1 skipped**
(process-scoped `model-list`). Event and row counts equal in every cell.
`recordings/parity-allowlist.json` names every intended
byte-inequivalence with `#2164` and a reason — 26 entries, four classes:
`presentation` on items (15 cells), `server: "bb"` on the
AskUserQuestion call (user-question), the sub-agent spawn as a
`delegation` item + its row (`subagent`, events `/6` `/28`, rows
`/0/children/0/{toolName,output}`), and `thread/goal/cleared` →
`provider-codex/goal` extension state (one event index in each of 6
goal-bearing cells). Zero unlisted diffs, zero stale entries.
claude-code **13/13** and acp-cursor **10/10** replay against main with
**zero diffs** (no entries).
- **Corpus (A4)**, 307 threads / 93,262 rows: **zero diffs**,
claude-code and codex alike; no corpus allowlist entry was needed (the
only read-time change, goals, projects identically by design).
- **G11**: `provider/unhandled` flat on every codex cell (0→0;
auth-failure 1→1) and on the corpus.
- **G1**: 209 → 209 (no provider-id literal added or removed in core;
`"provider-codex/goal"` is not a provider-id literal by the ratchet's
regex).
- **Parity self-suite** (#2153's, in the stack): 43/43 with the codex
current lanes re-recorded against this bridge; row-count pins
**unchanged** (every current lane assembles to exactly the pinned
counts); the claude/acp current lanes #2153 produced on WS1a's bridges
hold as they are.

Tests on the final stack (all forced, `--concurrency 4`, one turbo at a
time): db 406 (migration chain 0104 → 0105 → 0106 incl.
replay-on-existing-DB cases), agent-runtime 337, codex 193 (scripted +
recorded conformance), host-daemon-contract 52, provider-parity 43;
typecheck green for codex, agent-runtime, provider-parity, server, db.
Earlier full sweep before the re-stacks: domain 150, thread-view 382,
provider-bridge-protocol 218, plugin-sdk 127, host-daemon 552,
integration 55, claude-code 262, acp 182, workflows 223,
ask-user-question 36, scripted echo 1; server 1828/1829 (the one failure
is the known local umask case in `internal-skill-trees`, passes in CI);
typecheck green across 25 packages incl. app, mobile, cli.

## Not done

- **Live QA cells via bb-dev-app**
(turn/steer/stop/approve/deny/question/subagent/resume/fork/plan with
screenshots) — not run; the coordinator schedules them separately.
- `planSteps` rows are not projected by thread-view (no regression:
codex plans were discarded before); the projection workstream owns it.
- The plugin-rendered macOS approval (the profile round-trips only as a
visible row today) — WS5.

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 21, 2026
…on (#2179)

> **Stacked on #2178 (WS1b-claude).** Chain: contract → WS1a → WS2a →
codex #2164 → claude #2178 → **this PR** (6 commits above claude head
`61a0a9656`). Do not merge before the chain beneath it lands.

## What was wrong

The ACP bridge threw away the agent's native tool-call kind (`read`,
`edit`, `execute`, `search`, `fetch`, `think`, `other`) and wrote the
human `title` into the `tool` slot of a generic `tool` item
(`provider-acp/src/delta-translation.ts`, the old
`classifyAcpToolCall`). In the production database that is ~1,100 ACP
rows whose "tool name" is ``Read `/path/to/file` ``, plus `Web search:`,
`Updating plan`, `MCP: tool`, and `tool`. Generic tool permissions were
faked as `command` approvals (`interactions.ts`) because that was the
one subject with free text. No ACP item carried a presentation.

## What changed

One commit per layer; the last deletes.

1. **Presentation on every item** —
`plugins/provider-acp/src/presentation.ts`: a label pair and host glyph
per native kind, commands with the agent's Markdown code ticks stripped
from the headline, file changes by verb (write/edit/delete) with their
file names, plan snapshots, compactions. Every `item.open`/`item.close`
carries it.
2. **Native kind → core kinds; title → `presentation.title`** —
`plugins/provider-acp/src/tool-classification.ts`: `execute` →
`command`, diff / `edit` / `delete` with a path → `fileChange` (the
shared operation classifier the permission mapping uses is untouched,
#1803), `read` → `fileRead` when the path is known (`locations`, a
`rawInput` path field, or a single code-ticked token in the title — how
grok titles its reads), `search` → `search` when `rawInput` has a
pattern/query/regex (content) or glob (path), `fetch` → `webFetch` when
`rawInput` or the title carries the URL, `think` → `reasoning`.
Everything else (`other`, `move`, no kind, or a kind whose shape the
agent left unfilled — Cursor's `read`/`fetch` arrive with an empty
`rawInput` and no `locations`) is a generic `tool` whose `tool` slot is
the kind and whose presentation is the kind's label with the agent's
title as headline. A row is never a `fileRead` without a path. ACP
`plan` updates become settled `planSteps` snapshots (channel-keyed,
collapsed by default, read by the todo banner) instead of `turn.plan`.
3. **bb-injected tools (Q31)** — the translator learns the session's
`dynamicTools` at construction and binds each call the MCP proxy
forwards (`forwardDynamicToolCall` → `noteInjectedToolCall`) to the
agent's announcement. ACP gives no id linking the two (Cursor announces
every MCP call as `"MCP: tool"`, kind `other`), so the binding is
positional: the unbound `other` candidate whose title names the tool,
else one that mentions MCP, else the oldest; a proxied call with no
candidate open waits for the next announcement. A bound call's open or
close reads `{ server: "bb", tool, presentation }` from the
`DynamicTool` definition (generic under bb's glyph when the definition
predates the field); the close re-states it, so late binding still
settles as the bb tool.
4. **`tool_use` approval subject** — `interactions.ts` raises `{ kind:
"tool_use", itemId, tool, presentation }` for every permission that is
neither a command nor a file change, with the same classification the
timeline row gets. `apps/server/.../pending-interaction-timeline.ts`
stops throwing on the subject: a tool-use approval has no timeline item
of its own (the agent's own `tool_call` row is the record; the banner
renders the presentation) until WS5's interaction-lifecycle event. The
runtime conformance harness validates the subject instead of throwing;
the "no producer yet" comments on the client renderers are updated.
5. **Delete the ACP v2 translation path** — ACP was the last `turn.plan`
speaker, so the grammar member leaves `thread-delta.ts` and the
assembler under the grammar range (the way #2164 dropped
`thread.goal`/`thread/openWork`); `PROVIDER_BRIDGE_PROTOCOL_VERSION`
stays 2, the G3 snapshot loses the entry. The persisted
`turn/plan/updated` type stays as read-only history the view already
ignores.

**Wire:** `HOST_DAEMON_PROTOCOL_VERSION` stays at 150. Nothing this PR
sends between server and daemon changed shape: `tool_use` has been in
the pending-interaction schema since the contract PR, and
`DynamicTool.presentation` rode the wire since #2164.

**Interim UX to know about:** until the presentation-driven projection
lands, the legacy tool row shows the `tool` slot as its name, so a
generic ACP row reads `other`/`read`/`fetch` where it used to read the
agent's title (the title is in the persisted `presentation.title`, which
that projection will read). A denied `tool_use` approval leaves no
denied row in the timeline (the old fake command did); the agent's own
`tool_call_update` (usually `failed`) is the record.

## How you verified

Typecheck (turbo, `--concurrency 4`): `bb-plugin-provider-acp`,
`bb-plugin-provider-codex`, `bb-plugin-provider-claude-code`,
`@bb/agent-runtime`, `@bb/server`, `@bb/provider-bridge-protocol`,
`@get-bb/plugin-sdk`. npm version guard: PASS (0.4.11). eslint +
prettier on every changed file.

Tests: `bb-plugin-provider-acp` 196/196 (new: presentation coverage,
native-kind mapping incl. title-path/URL recovery and the unfilled-shape
fallbacks, bb-injected binding in Cursor order / proxy-first order /
by-name / never-a-command, `tool_use` subjects incl. bound bb tool and
in-flight-call headline); `@bb/provider-bridge-protocol` 217/217;
`@bb/agent-runtime` 439/439; `@bb/thread-view` 385/385; `@bb/domain`,
`@bb/core-ui`, `@get-bb/plugin-sdk`; `@bb/server` pending-interactions /
internal-interactive-requests / public-thread-interactions (65/65, new:
tool_use approval registers, appends no item event, resolves with deny).

**Conformance (acp-cursor):** scripted protocol suite against the fake
agent: pass. Recorded conformance: all 11 committed cells replayed
through this bridge (10 replayable + process-scoped model-list), every
rule passes.

**Parity A2** (`pnpm parity --old ~/.bb/parity-old-main --new .
--provider <id>`; old = `origin/main` f6fb434):
- acp-cursor **10 passed, 0 failed, 1 skipped**, zero unlisted, zero
stale. Allowlist entries (17, all `#2179`):
- `events /*/item/presentation` × approval-allow, approval-deny, steer,
stop-interrupt, subagent, turn-tools, user-question, web-search —
presentation is new; items otherwise byte-identical.
- `events /*/item/tool` × subagent, turn-tools, user-question,
web-search — `"Task: Subagent task"`→`other`, `"Read File"`→`read`,
`"Edit File"`→`edit`, `"MCP: tool"`→`other`, `"Web Fetch"`→`fetch`; the
title now rides `presentation.title`.
- `rows /0/children/1/toolName` × subagent, turn-tools, user-question,
web-search and `rows /0/children/3/toolName` × turn-tools — the legacy
row projects the tool slot.
- codex **16 passed, 0 failed, 1 skipped** — zero new diffs, zero stale.
- claude-code **13 passed, 0 failed, 1 skipped** — zero new diffs, zero
stale.
- acp-cursor `bridge→runtime.current` lanes re-recorded (`pnpm rerecord
--provider acp-cursor --plan-with ~/.bb/parity-old-main`); parity
self-suite 43/43; `row-counts.json` pins unchanged.

**Corpus A4:** `BB_PROVIDER_CORPUS_SNAPSHOT=compare` → 307/307 threads,
93,262 rows, `diffThreads: []`, `allowedDiffs: 0`; timeline-perf gate
21/21 (load 4.4 on 16 cores). **A4 cannot prove ACP:** the extracted
corpus is codex (166) + claude-code (141) only; the 29 ACP production
threads (acp-grok 25, acp-cursor 2, acp-pi-acp 2) were not extracted.
What A4 proves here is that this PR does not touch codex or claude rows.

**G11:** acp-cursor `provider/unhandled` 0 → 0 in every cell (pins
unchanged); the one pre-existing grammar drop in turn-tools is 1 → 1.
The production ACP unhandled rows (155, all grok: `tool_call_update` 83,
`agent_message_chunk` 37, `agent_thought_chunk` 35) are
outside-turn/visibility residue the kind mapping does not touch; no ACP
corpus exists to re-measure them.

## What the recordings cannot prove (needs live QA)

- **The native-kind → core-kind class is not in any recording.**
Cursor's `read`/`fetch` calls carry no path/URL, so in acp-cursor every
such row stays a generic `tool` (the `/*/item/tool` class above).
`fileRead`, `webFetch`, `search`, `reasoning` from ACP are covered by
unit tests only. The "every read/edit/execute row changes kind" class
the spec predicted did not materialize on this agent; grok (which titles
reads with the path) is the agent that will exercise it.
- **Q31 binding is not replayable.** The recorded user-question cell had
1 `item/tool/call`; a replay has 0 because the MCP proxy socket is not
part of a recording, so the replayed row is `other`, not
`ask_user_question`. The positional binding is unit-tested in both
orders; live QA with Cursor + ask-user-question is needed.
- **`tool_use` is not in any recording:** both approval cells are
`execute` (command subject). Unit tests + the server lifecycle test
cover it; no generic-permission cell exists for acp.
- **No plan-mode, compaction, or auth-failure cell for acp** (not
capturable at recording time); **fork is recorded as the agent's
refusal** (`fork: "tip"`, 0 events) and stays that way.
- The positional Q31 binding is a heuristic: two parallel MCP calls
announced in a different order than they execute would swap labels
(never the flow).

> AGENT GENERATED: by Claude Opus 5

---------

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