Skip to content

Commit deaa6f6

Browse files
committed
fix(agents): preserve trailing assistant tool-call turns during session trim
Mirror the outbound guard (stripTrailingAssistantPrefillTurns): skip assistant entries containing toolCall/toolUse/functionCall blocks so transcript repair can synthesize missing tool results. Addresses PR review feedback from clawsweeper on #75606.
1 parent 9615a8b commit deaa6f6

2 files changed

Lines changed: 89 additions & 3 deletions

File tree

src/agents/session-file-repair.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,73 @@ describe("repairSessionFileIfNeeded", () => {
409409
expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0);
410410
});
411411

412+
it("preserves trailing assistant messages that contain tool calls", async () => {
413+
const { file } = await createTempSessionPath();
414+
const { header, message } = buildSessionHeaderAndMessage();
415+
const toolCallAssistant = {
416+
type: "message",
417+
id: "msg-asst-tc",
418+
parentId: null,
419+
timestamp: new Date().toISOString(),
420+
message: {
421+
role: "assistant",
422+
content: [
423+
{ type: "text", text: "Let me check that." },
424+
{ type: "toolCall", id: "call_1", name: "read", input: { path: "/tmp/test" } },
425+
],
426+
stopReason: "toolUse",
427+
},
428+
};
429+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n`;
430+
await fs.writeFile(file, original, "utf-8");
431+
432+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
433+
434+
expect(result.repaired).toBe(false);
435+
expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0);
436+
const after = await fs.readFile(file, "utf-8");
437+
expect(after).toBe(original);
438+
});
439+
440+
it("trims non-tool-call assistant but stops at tool-call assistant", async () => {
441+
const { file } = await createTempSessionPath();
442+
const { header, message } = buildSessionHeaderAndMessage();
443+
const toolCallAssistant = {
444+
type: "message",
445+
id: "msg-asst-tc",
446+
parentId: null,
447+
timestamp: new Date().toISOString(),
448+
message: {
449+
role: "assistant",
450+
content: [{ type: "toolUse", id: "call_1", name: "read" }],
451+
stopReason: "toolUse",
452+
},
453+
};
454+
const plainAssistant = {
455+
type: "message",
456+
id: "msg-asst-plain",
457+
parentId: null,
458+
timestamp: new Date().toISOString(),
459+
message: {
460+
role: "assistant",
461+
content: [{ type: "text", text: "stale" }],
462+
stopReason: "stop",
463+
},
464+
};
465+
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(plainAssistant)}\n`;
466+
await fs.writeFile(file, original, "utf-8");
467+
468+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
469+
470+
expect(result.repaired).toBe(true);
471+
expect(result.trimmedTrailingAssistantMessages).toBe(1);
472+
473+
const repaired = await fs.readFile(file, "utf-8");
474+
const repairedLines = repaired.trim().split("\n");
475+
expect(repairedLines).toHaveLength(3);
476+
expect(JSON.parse(repairedLines[2]).id).toBe("msg-asst-tc");
477+
});
478+
412479
it("never trims below the session header", async () => {
413480
const { file } = await createTempSessionPath();
414481
const { header } = buildSessionHeaderAndMessage();

src/agents/session-file-repair.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,34 @@ function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEn
136136
};
137137
}
138138

139-
function isAssistantMessageEntry(entry: unknown): boolean {
139+
function isToolCallBlock(block: unknown): boolean {
140+
if (!block || typeof block !== "object") {
141+
return false;
142+
}
143+
const type = (block as { type?: unknown }).type;
144+
return type === "toolCall" || type === "toolUse" || type === "functionCall";
145+
}
146+
147+
/** Trailing assistant without tool calls — safe to trim from disk.
148+
* Assistant turns with tool calls are kept so transcript repair can
149+
* synthesize missing tool results (mirrors the outbound guard). */
150+
function isTrimmableTrailingAssistantEntry(entry: unknown): boolean {
140151
if (!entry || typeof entry !== "object") {
141152
return false;
142153
}
143154
const record = entry as { type?: unknown; message?: unknown };
144155
if (record.type !== "message" || !record.message || typeof record.message !== "object") {
145156
return false;
146157
}
147-
return (record.message as { role?: unknown }).role === "assistant";
158+
const message = record.message as { role?: unknown; content?: unknown };
159+
if (message.role !== "assistant") {
160+
return false;
161+
}
162+
const content = message.content;
163+
if (Array.isArray(content) && content.some(isToolCallBlock)) {
164+
return false;
165+
}
166+
return true;
148167
}
149168

150169
function buildRepairSummaryParts(params: {
@@ -252,7 +271,7 @@ export async function repairSessionFileIfNeeded(params: {
252271
// thinking is enabled. The outbound path strips per-request, but leaving
253272
// the file corrupted causes repeated reject cycles across restarts.
254273
let trimmedTrailingAssistantMessages = 0;
255-
while (entries.length > 1 && isAssistantMessageEntry(entries[entries.length - 1])) {
274+
while (entries.length > 1 && isTrimmableTrailingAssistantEntry(entries[entries.length - 1])) {
256275
entries.pop();
257276
trimmedTrailingAssistantMessages += 1;
258277
}

0 commit comments

Comments
 (0)