fix(server): show diffs for ACP edits that send oldText/newText - #13579
Conversation
ACP v1 tool-call diff content is { type: "diff", path, oldText, newText };
v2 replaced it with { changes, patch }. The ACP adapter read only the v2
patch.text, so file changes from agents that negotiate v1 (Grok answers
initialize with protocolVersion 1) reached the timeline with no diff.
Build the patch from oldText/newText with the server's existing jsdiff
dependency (/dev/null for a new file), keeping v2 patch text as sent.
The Grok read-only on-request replay now requires the file_change to carry
what it wrote.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This is a small, self-contained ACP adapter bug fix that adds generated diff text for legacy edit payloads while preserving existing v2 handling. The runtime impact is limited to file-change presentation, with targeted tests and a safeguard against expensive large rewrites. You can add or adjust custom eligibility rules. Learn more. |
1f2f91d
into
t3code/codex-turn-mapping
Grok's file edits reached the V2 timeline with no diff. Grok sends tool-call diff content as
{ type: "diff", path, oldText, newText }, but the ACP adapter read onlypatch.text, so thefile_changeitem had nodiffStr.Is it part of the ACP spec, or a v1 vs v2 difference?
It's a v1 vs v2 difference. Each shape belongs to exactly one protocol version. T3 vendors both schemas in
effect-acp(generator pins:schema-v1.21.0andschema-v2.0.0-alpha.3).schema-v1.21.0):ToolCallContentdiff is{ type: "diff", path, oldText?: string | null, newText }.oldTextis documented as "The original content (None for new files)". There is nopatchfield. Seeschema-v1.gen.ts#L5185andDiffat #L8202.schema-v2.0.0-alpha.3): the diff is{ type: "diff", changes: DiffChange[], patch?: { format: "git_patch", text } | null }. It has nopath,oldTextornewText.changesis authoritative, and "Clients MUST handle diffs wherepatchis omitted ornull". Seeschema.gen.ts#L5863,Diffat #L8528 andDiffPatchat #L2803.compat.ts#L249), andAcpRuntimeModelkeeps both (#L379). T3 asks for v2 ininitialize(client.ts#L728) but falls back to v1 when the agent answers with the v1 response shape (#L1124-L1128). A v1 session'stool_callupdates pass through unchanged (normalizeV1SessionUpdate). The one place the v1 diff was lost was the adapter'sfile_changeprojection.What Grok sends
Grok is on ACP v1. It builds on
agent-client-protocol0.10.4, and atf0e3be1it answersinitializewithProtocolVersion::V1(acp_agent.rs:533). Every file edit isacp::Diff::new(path, new_text).old_text(...), which is the v1 shape:write, on the pending call:old_text(Some(""))(tool_calls.rs:2604)search_replace, on the pending call:old_text(Some(old_string))(tool_calls.rs:2367)search_replaceresult:acp_conversion.rs:206apply_patchresult, oneDiffper file,old_text: Nonefor a new file:acp_conversion.rs:449Grok's own TUI renders these by diffing
old_text/new_textitself (xai-grok-pager-diff/src/lib.rs:317).The live recording from #13562 (Grok 1.0.41) shows the same.
initializeanswersprotocolVersion: 1(transcript line 3). The write reports{ type: "diff", path: "<workspace>/.codex-probe-write-action.txt", oldText: "", newText: "codex app-server approval fixture." }on its pending and completed updates (lines 95 and 103).So Grok is behind the v2 spec rather than off-spec. It sends the correct shape for the version it negotiates.
Fix
T3 follows ACP v2. It should still render what a provider actually sends after negotiating v1.
acpToolCallDiffPatchreplacesstructuredDiffPatchand stays at the adapter boundary:patch.textis returned as sent, exactly as before.{ path, oldText, newText }entry becomes a unified patch through the server's existingdiff(jsdiff 8.0.3) dependency, already used inazureDevOpsDiff.ts. No new library.oldTextnull or absent diffs against/dev/null. Multiple files are joined into one patch, and unchanged entries are skipped.For Grok's recorded write the item now carries:
(
oldText: ""means Grok says the file existed and was empty, so the header names the file rather than/dev/null.)The shared
tool_call_read_only_on_requestassertion no longer skips afile_changethat carries no content. #13562 added that skip only because of this bug.Scope:
diffStrstays in persistence and is stripped from the wire projection (WireProjection.ts). Today it is read by the orchestrator MCP timeline text (OrchestratorMcpService.ts:672), not by the web or mobile timeline. This PR fixes what the ACP adapter records. It does not change any client.Verification
AssertionError: the approved file_change must carry what it wrote: expected undefined to not equal undefined(OrchestratorReplayFixtures.integration.test.ts -t tool_call_read_only_on_request: 1 failed, 3 passed). With the fix: 4 passed.vp test runonOrchestratorReplayFixtures.integration.test.ts,OrchestratorReplayFixtures.contract.test.ts,AcpAdapterV2.test.ts,GrokAdapterV2.test.ts,AcpRegistryAdapterV2.test.tsandAcpRuntimeModel.test.ts: 6 files, 268 tests passed.acpToolCallDiffPatchhas pure-logic tests for the multi-file v1 patch with/dev/null, v2 patch pass-through, and the rewrite cap.vp exec tsc --noEmit -p .in apps/server gave 0error TS/warning TS.vp run knip:checkis clean.vp linton the touched files adds no new warnings (the three it reports inAcpAdapterV2.test.tsare pre-existing).vp fmtis clean.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code