Skip to content

Commit 84bfe68

Browse files
authored
fix(agent): rebuild resume transcripts from ACP top-level tool fields (#3084)
1 parent ea246be commit 84bfe68

2 files changed

Lines changed: 227 additions & 24 deletions

File tree

packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
conversationTurnsToJsonlEntries,
55
getSessionJsonlPath,
66
rebuildConversation,
7+
selectRecentTurns,
78
} from "./jsonl-hydration";
89

910
function entry(
@@ -285,6 +286,118 @@ describe("rebuildConversation", () => {
285286
expect(turns[1].toolCalls).toHaveLength(1);
286287
expect(turns[1].toolCalls?.[0].result).toBeUndefined();
287288
});
289+
290+
it("tracks tool calls from the ACP shape: top-level toolCallId/rawInput/rawOutput, toolName in _meta", () => {
291+
// Mirrors the exact update sequence agent-server persists to S3. Before
292+
// the top-level fields were read, every tool call was dropped and a
293+
// 30-minute run resumed as a 4-line transcript.
294+
const turns = rebuildConversation([
295+
entry("user_message", { content: { type: "text", text: "fix it" } }),
296+
entry("tool_call", {
297+
toolCallId: "toolu_01",
298+
_meta: { claudeCode: { toolName: "Bash" } },
299+
rawInput: {},
300+
status: "pending",
301+
title: "Execute command",
302+
kind: "execute",
303+
content: [],
304+
}),
305+
entry("tool_call_update", {
306+
toolCallId: "toolu_01",
307+
rawInput: { command: "gh pr view 123" },
308+
}),
309+
entry("tool_call_update", {
310+
toolCallId: "toolu_01",
311+
_meta: { claudeCode: { toolName: "Bash" } },
312+
status: "completed",
313+
rawOutput: { stdout: "PR title" },
314+
}),
315+
]);
316+
317+
expect(turns).toHaveLength(2);
318+
expect(turns[1].toolCalls).toEqual([
319+
{
320+
toolCallId: "toolu_01",
321+
toolName: "Bash",
322+
input: { command: "gh pr view 123" },
323+
result: { stdout: "PR title" },
324+
},
325+
]);
326+
});
327+
328+
it("truncates oversized tool payloads, keeping object inputs as objects", () => {
329+
const bigOutput = "x".repeat(50_000);
330+
const bigInput = { file_path: "/tmp/big.ts", content: "y".repeat(50_000) };
331+
const turns = rebuildConversation([
332+
entry("user_message", { content: { type: "text", text: "go" } }),
333+
entry("tool_call", {
334+
toolCallId: "toolu_01",
335+
_meta: { claudeCode: { toolName: "Write" } },
336+
rawInput: bigInput,
337+
}),
338+
entry("tool_call_update", {
339+
toolCallId: "toolu_01",
340+
rawOutput: bigOutput,
341+
}),
342+
]);
343+
344+
// String outputs may truncate to a string; tool_use.input must stay an
345+
// object per the Claude API schema.
346+
const result = turns[1].toolCalls?.[0].result as string;
347+
expect(result.length).toBeLessThan(11_000);
348+
expect(result).toContain("[truncated");
349+
350+
const input = turns[1].toolCalls?.[0].input as {
351+
_truncated: boolean;
352+
preview: string;
353+
originalSize: number;
354+
};
355+
expect(input._truncated).toBe(true);
356+
expect(input.preview.length).toBeLessThan(11_000);
357+
expect(input.originalSize).toBeGreaterThan(50_000);
358+
});
359+
});
360+
361+
describe("selectRecentTurns", () => {
362+
it("keeps the user turn and sheds oldest tool calls when the final turn alone exceeds the budget", () => {
363+
// A single-prompt run rebuilds into [user, one giant assistant turn].
364+
// Before the fallback, that shape selected zero turns and hydration
365+
// wrote an empty transcript.
366+
const bigInput = { data: "y".repeat(8_000) };
367+
const turns = rebuildConversation([
368+
entry("user_message", { content: { type: "text", text: "the task" } }),
369+
...[1, 2, 3].map((i) =>
370+
entry("tool_call", {
371+
toolCallId: `toolu_0${i}`,
372+
_meta: { claudeCode: { toolName: "Bash" } },
373+
rawInput: bigInput,
374+
}),
375+
),
376+
]);
377+
378+
// Budget fits the user turn plus roughly one big tool call.
379+
const selected = selectRecentTurns(turns, 3_000);
380+
381+
expect(selected).toHaveLength(2);
382+
expect(selected[0].role).toBe("user");
383+
expect(selected[1].role).toBe("assistant");
384+
const keptIds = selected[1].toolCalls?.map((tc) => tc.toolCallId);
385+
expect(keptIds).toEqual(["toolu_03"]);
386+
});
387+
388+
it("returns recent turns that fit the budget unchanged", () => {
389+
const turns = [
390+
{
391+
role: "user" as const,
392+
content: [{ type: "text" as const, text: "a" }],
393+
},
394+
{
395+
role: "assistant" as const,
396+
content: [{ type: "text" as const, text: "b" }],
397+
},
398+
];
399+
expect(selectRecentTurns(turns, 1_000)).toEqual(turns);
400+
});
288401
});
289402

290403
describe("conversationTurnsToJsonlEntries", () => {

packages/agent/src/adapters/claude/session/jsonl-hydration.ts

Lines changed: 114 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,37 @@ interface SessionUpdate {
4242
sessionUpdate: string;
4343
content?: ContentBlock | ContentBlock[];
4444
_meta?: { claudeCode?: ClaudeCodeMeta };
45+
// ACP puts these on the update itself; _meta.claudeCode only reliably
46+
// carries toolName (and sometimes toolResponse).
47+
toolCallId?: string;
48+
rawInput?: unknown;
49+
rawOutput?: unknown;
50+
}
51+
52+
// Individual tool payloads can be huge (whole-file Write inputs, full test
53+
// output). Cap each one so a single call can't dominate the resume budget.
54+
const MAX_TOOL_PAYLOAD_CHARS = 10_000;
55+
56+
function capToolPayload(value: unknown): unknown {
57+
const text = typeof value === "string" ? value : JSON.stringify(value);
58+
if (typeof text !== "string" || text.length <= MAX_TOOL_PAYLOAD_CHARS) {
59+
return value;
60+
}
61+
const preview = `${text.slice(0, MAX_TOOL_PAYLOAD_CHARS)}… [truncated ${text.length - MAX_TOOL_PAYLOAD_CHARS} chars]`;
62+
// tool_use.input must stay an object per the Claude API schema — wrap
63+
// instead of replacing with a bare string.
64+
return typeof value === "string"
65+
? preview
66+
: { _truncated: true, preview, originalSize: text.length };
67+
}
68+
69+
function isEmptyRecord(value: unknown): boolean {
70+
return (
71+
typeof value === "object" &&
72+
value !== null &&
73+
!Array.isArray(value) &&
74+
Object.keys(value).length === 0
75+
);
4576
}
4677

4778
const MAX_PROJECT_KEY_LENGTH = 200;
@@ -148,36 +179,47 @@ export function rebuildConversation(
148179
case "tool_call":
149180
case "tool_call_update": {
150181
const meta = update._meta?.claudeCode;
151-
if (meta) {
152-
const { toolCallId, toolName, toolInput, toolResponse } = meta;
153-
154-
if (toolCallId && toolName) {
155-
let toolCall = currentToolCalls.find(
156-
(tc) => tc.toolCallId === toolCallId,
157-
);
158-
if (!toolCall) {
159-
toolCall = { toolCallId, toolName, input: toolInput };
160-
currentToolCalls.push(toolCall);
161-
}
162-
if (toolResponse !== undefined) {
163-
toolCall.result = toolResponse;
164-
}
165-
}
182+
const toolCallId = update.toolCallId ?? meta?.toolCallId;
183+
if (!toolCallId) break;
184+
185+
let toolCall = currentToolCalls.find(
186+
(tc) => tc.toolCallId === toolCallId,
187+
);
188+
if (!toolCall) {
189+
const toolName = meta?.toolName;
190+
// Bare streaming updates carry no name; the opening tool_call
191+
// always does, so the call exists by the time they arrive.
192+
if (!toolName) break;
193+
toolCall = { toolCallId, toolName, input: undefined };
194+
currentToolCalls.push(toolCall);
195+
}
196+
197+
const input = update.rawInput ?? meta?.toolInput;
198+
// The opening tool_call ships rawInput: {} — don't clobber an
199+
// already-streamed input with it.
200+
if (
201+
input !== undefined &&
202+
!(isEmptyRecord(input) && toolCall.input !== undefined)
203+
) {
204+
toolCall.input = capToolPayload(input);
205+
}
206+
const result = update.rawOutput ?? meta?.toolResponse;
207+
if (result !== undefined) {
208+
toolCall.result = capToolPayload(result);
166209
}
167210
break;
168211
}
169212

170213
case "tool_result": {
171214
const meta = update._meta?.claudeCode;
172-
if (meta) {
173-
const { toolCallId, toolResponse } = meta;
174-
if (toolCallId) {
175-
const toolCall = currentToolCalls.find(
176-
(tc) => tc.toolCallId === toolCallId,
177-
);
178-
if (toolCall && toolResponse !== undefined) {
179-
toolCall.result = toolResponse;
180-
}
215+
const toolCallId = update.toolCallId ?? meta?.toolCallId;
216+
if (toolCallId) {
217+
const toolCall = currentToolCalls.find(
218+
(tc) => tc.toolCallId === toolCallId,
219+
);
220+
const result = update.rawOutput ?? meta?.toolResponse;
221+
if (toolCall && result !== undefined) {
222+
toolCall.result = capToolPayload(result);
181223
}
182224
}
183225
break;
@@ -236,6 +278,15 @@ export function selectRecentTurns(
236278
startIndex = i;
237279
}
238280

281+
if (startIndex === turns.length && turns.length > 0) {
282+
// Even the most recent turn alone exceeds the budget — typical for a
283+
// single-prompt run, where everything after the prompt is one giant
284+
// assistant turn. Resuming with nothing loses all context, so keep the
285+
// nearest user turn (the task intent) and shed the assistant turn's
286+
// oldest tool calls until it fits.
287+
return selectOversizedTailFallback(turns, maxTokens);
288+
}
289+
239290
// Ensure we start on a user turn so the conversation is well-formed
240291
while (startIndex < turns.length && turns[startIndex].role !== "user") {
241292
startIndex++;
@@ -244,6 +295,45 @@ export function selectRecentTurns(
244295
return turns.slice(startIndex);
245296
}
246297

298+
function selectOversizedTailFallback(
299+
turns: ConversationTurn[],
300+
maxTokens: number,
301+
): ConversationTurn[] {
302+
const last = turns[turns.length - 1];
303+
304+
let userIndex = turns.length - 1;
305+
while (userIndex >= 0 && turns[userIndex].role !== "user") {
306+
userIndex--;
307+
}
308+
309+
const selected: ConversationTurn[] = [];
310+
let budget = maxTokens;
311+
if (userIndex >= 0) {
312+
selected.push(turns[userIndex]);
313+
budget -= estimateTurnTokens(turns[userIndex]);
314+
}
315+
if (userIndex !== turns.length - 1) {
316+
selected.push(dropOldestToolCalls(last, Math.max(budget, 0)));
317+
}
318+
return selected;
319+
}
320+
321+
function dropOldestToolCalls(
322+
turn: ConversationTurn,
323+
budget: number,
324+
): ConversationTurn {
325+
if (!turn.toolCalls?.length) return turn;
326+
const toolCalls = [...turn.toolCalls];
327+
const trimmed: ConversationTurn = { ...turn, toolCalls };
328+
while (toolCalls.length > 0 && estimateTurnTokens(trimmed) > budget) {
329+
toolCalls.shift();
330+
}
331+
if (toolCalls.length === 0) {
332+
trimmed.toolCalls = undefined;
333+
}
334+
return trimmed;
335+
}
336+
247337
const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
248338

249339
function generateMessageId(): string {

0 commit comments

Comments
 (0)