Skip to content

Commit 4419c2f

Browse files
authored
fix(agent): default missing tool_use input to {} in session hydration (#3296)
1 parent 12bc99f commit 4419c2f

2 files changed

Lines changed: 92 additions & 10 deletions

File tree

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,30 @@ describe("conversationTurnsToJsonlEntries", () => {
548548
expect(conv[2].parentUuid).toBe(conv[1].uuid);
549549
});
550550

551+
it.each([undefined, null])(
552+
"emits input: {} for tool calls whose input is %s",
553+
(missingInput) => {
554+
const lines = conversationTurnsToJsonlEntries(
555+
[
556+
{
557+
role: "assistant",
558+
content: [],
559+
toolCalls: [
560+
{ toolCallId: "tc-1", toolName: "Bash", input: missingInput },
561+
],
562+
},
563+
],
564+
config,
565+
);
566+
567+
const conv = parseConversationEntries(lines);
568+
expect(conv).toHaveLength(1);
569+
expect(conv[0].message.content).toEqual([
570+
{ type: "tool_use", id: "tc-1", name: "Bash", input: {} },
571+
]);
572+
},
573+
);
574+
551575
it("sets stop_reason only on last block, null on intermediate", () => {
552576
const lines = conversationTurnsToJsonlEntries(
553577
[
@@ -1132,6 +1156,29 @@ describe("end-to-end: S3 log entries -> JSONL output", () => {
11321156
expect(msg1.id).toBe(msg2.id);
11331157
expect(msg2.id).toBe(msg3.id);
11341158
});
1159+
1160+
it("emits input: {} when the tool input never reached the logs", () => {
1161+
const s3Logs: StoredEntry[] = [
1162+
s3Entry("user_message", {
1163+
content: { type: "text", text: "run the tests" },
1164+
}),
1165+
s3Entry("tool_call", {
1166+
toolCallId: "tc-lost",
1167+
_meta: { claudeCode: { toolName: "Bash" } },
1168+
}),
1169+
];
1170+
1171+
const turns = rebuildConversation(s3Logs);
1172+
const lines = conversationTurnsToJsonlEntries(turns, config);
1173+
const conv = filterConv(lines.map((l) => JSON.parse(l)));
1174+
1175+
const toolUseLine = conv.find((e) => e.type === "assistant");
1176+
expect(toolUseLine).toBeDefined();
1177+
const content = (toolUseLine?.message as { content: unknown[] }).content;
1178+
expect(content).toEqual([
1179+
{ type: "tool_use", id: "tc-lost", name: "Bash", input: {} },
1180+
]);
1181+
});
11351182
});
11361183

11371184
describe("sanitizeSessionJsonl", () => {
@@ -1242,6 +1289,32 @@ describe("sanitizeSessionJsonl", () => {
12421289
]);
12431290
});
12441291

1292+
it.each([
1293+
["a missing", { type: "tool_use", id: "tc-1", name: "Bash" }],
1294+
["a null", { type: "tool_use", id: "tc-1", name: "Bash", input: null }],
1295+
])("adds input: {} to tool_use blocks with %s input", async (_, block) => {
1296+
const file = await writeJsonl([
1297+
{
1298+
type: "assistant",
1299+
uuid: "a1",
1300+
parentUuid: null,
1301+
message: {
1302+
role: "assistant",
1303+
content: [{ type: "text", text: "running" }, block],
1304+
},
1305+
},
1306+
]);
1307+
1308+
expect(await sanitizeSessionJsonl(file)).toBe(true);
1309+
1310+
const lines = await readJsonl(file);
1311+
const assistant = lines[0].message as { content: unknown };
1312+
expect(assistant.content).toEqual([
1313+
{ type: "text", text: "running" },
1314+
{ type: "tool_use", id: "tc-1", name: "Bash", input: {} },
1315+
]);
1316+
});
1317+
12451318
it("sanitizes empty blocks in user lines too", async () => {
12461319
const file = await writeJsonl([
12471320
{

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,14 @@ export function rebuildConversation(
195195
// Bare streaming updates carry no name; the opening tool_call
196196
// always does, so the call exists by the time they arrive.
197197
if (!toolName) break;
198-
toolCall = { toolCallId, toolName, input: undefined };
198+
toolCall = { toolCallId, toolName, input: {} };
199199
currentToolCalls.push(toolCall);
200200
}
201201

202202
const input = update.rawInput ?? meta?.toolInput;
203203
// The opening tool_call ships rawInput: {} — don't clobber an
204204
// already-streamed input with it.
205-
if (
206-
input !== undefined &&
207-
!(isEmptyRecord(input) && toolCall.input !== undefined)
208-
) {
205+
if (input !== undefined && !isEmptyRecord(input)) {
209206
toolCall.input = capToolPayload(input);
210207
}
211208
const result = update.rawOutput ?? meta?.toolResponse;
@@ -496,7 +493,8 @@ export function conversationTurnsToJsonlEntries(
496493
type: "tool_use",
497494
id: tc.toolCallId,
498495
name: tc.toolName,
499-
input: tc.input,
496+
// undefined would be dropped on stringify; the API requires input
497+
input: tc.input ?? {},
500498
});
501499
}
502500
}
@@ -594,8 +592,9 @@ interface HydrationLog {
594592
warn: (msg: string, data?: unknown) => void;
595593
}
596594

597-
// Heals JSONL files written before the empty-block filters existed; without
598-
// this an already-poisoned transcript keeps 400ing on every resume.
595+
// Heals JSONL files written before the empty-block and missing-tool_use-input
596+
// fixes existed; without this an already-poisoned transcript keeps 400ing on
597+
// every resume.
599598
export async function sanitizeSessionJsonl(
600599
jsonlPath: string,
601600
): Promise<boolean> {
@@ -619,10 +618,20 @@ export async function sanitizeSessionJsonl(
619618
}
620619
const message = parsed.message as { content?: unknown } | undefined;
621620
if (!message || !Array.isArray(message.content)) return line;
621+
let lineChanged = false;
622622
const kept = message.content.filter((block) => !isEmptyContentBlock(block));
623-
if (kept.length === message.content.length) return line;
623+
if (kept.length !== message.content.length) {
624+
lineChanged = true;
625+
message.content = kept.length > 0 ? kept : [{ type: "text", text: " " }];
626+
}
627+
for (const block of message.content as (Record<string, unknown> | null)[]) {
628+
if (block?.type === "tool_use" && block.input == null) {
629+
block.input = {};
630+
lineChanged = true;
631+
}
632+
}
633+
if (!lineChanged) return line;
624634
changed = true;
625-
message.content = kept.length > 0 ? kept : [{ type: "text", text: " " }];
626635
return JSON.stringify(parsed);
627636
});
628637

0 commit comments

Comments
 (0)