fix: replay a session with a Write on the experimental ACP v2 surface (#1238)

Fixes a bug in #1237 on the opt-in ACP v2 surface. Nothing changes on
ACP v1.

## Problem

A v2 `session/resume` with `replayFrom` failed with an internal error on
any session that contains a Write:
- A replay has no approval preview and no PostToolUse hook, so the Write
reporter builds a diff from the input for every client on replay.
- That's a v1 diff. It even claims a creation, since the input doesn't
say whether the file existed.
- The v2 surface rejects v1 diffs (#1232), so the replay threw and the
resume failed.

`session-load-replay` has an Edit but no Write, so the scenario suite
didn't catch it.

## Change

A v2 client gets no diff from a Write's input, live or replayed: a v2
diff names the operation (`add` or `modify`), which the input doesn't
tell. Replayed Writes and Edits show no diff on v2 yet; building them
needs the tool's structured result, which the SDK's transcript reader
leaves out (see the follow-up discussion).

## Verification

A new test replays a transcript with a Write through a v2
`session/resume`: it failed with "Internal error" before the change and
passes after. The v1 golden files are unchanged. Build, lint and all
2386 tests pass.
This commit is contained in:
Ben Brandt
2026-10-02 09:44:51 -07:00
committed by GitHub
parent bd8a161c2e
commit 22139f73c4
2 changed files with 47 additions and 2 deletions

View File

@@ -740,6 +740,49 @@ describe("ACP v2 prompts", () => {
]);
});
it("replays a Write without the v1 diff that v2 cannot take", async () => {
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const file = path.join(cwd, "a.ts");
vi.mocked(getSessionMessages).mockResolvedValueOnce([
transcriptEntry("assistant", randomUUID(), {
id: "msg_write",
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_w",
name: "Write",
input: { file_path: file, content: "x\n" },
},
],
}),
transcriptEntry("user", randomUUID(), {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_w",
content: `File created successfully at: ${file}`,
},
],
}),
] as unknown as Awaited<ReturnType<typeof getSessionMessages>>);
await agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
replayFrom: { type: "start" },
});
await client.authUpdate(1);
});
expect(client.updates("tool_call_update")).toContainEqual(
expect.objectContaining({ toolCallId: "toolu_w", title: "Write a.ts", content: [] }),
);
});
it("replays the history before it answers session/resume, clearing each message first", async () => {
const echoed = scriptTurns(
async function* (options) {

View File

@@ -37,8 +37,10 @@ export class WriteReporter implements ToolReporter {
// whether the file exists, so the live tool call shows no diff: a diff
// without the old text would claim a creation. The adapter reads the
// file only for the preview. A replay has no preview and no hook, so it
// keeps the diff.
if (!(capabilities.diffPatch || capabilities.v2) || replay) {
// keeps the diff, except for a v2 client: a v2 diff names the operation,
// which the input does not tell.
const inputDiff = capabilities.v2 ? false : !capabilities.diffPatch || replay;
if (inputDiff) {
facts.change = [
{
type: "diff",