From fe71e0f4c5004b91b3793e912c1ea36869018c2a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 3 May 2026 09:07:23 +0530 Subject: [PATCH] fix(agents): preserve repaired session assistant history --- src/agents/session-file-repair.test.ts | 49 ++++++----------- src/agents/session-file-repair.ts | 73 +------------------------- 2 files changed, 19 insertions(+), 103 deletions(-) diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts index 7531d6dbe43..b8cbfc545ec 100644 --- a/src/agents/session-file-repair.test.ts +++ b/src/agents/session-file-repair.test.ts @@ -172,7 +172,6 @@ describe("repairSessionFileIfNeeded", () => { expect(result.repaired).toBe(true); expect(result.rewrittenUserMessages).toBe(1); - expect(result.droppedBlankUserMessages).toBe(0); expect(debug.mock.calls[0]?.[0]).toContain("rewrote 1 user message(s)"); const repaired = await fs.readFile(file, "utf-8"); @@ -312,7 +311,7 @@ describe("repairSessionFileIfNeeded", () => { expect(after).toBe(original); }); - it("trims trailing assistant messages from the session file", async () => { + it("preserves delivered trailing assistant messages in the session file", async () => { const { file } = await createTempSessionPath(); const { header, message } = buildSessionHeaderAndMessage(); const assistantEntry = { @@ -329,19 +328,15 @@ describe("repairSessionFileIfNeeded", () => { const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(assistantEntry)}\n`; await fs.writeFile(file, original, "utf-8"); - const debug = vi.fn(); - const result = await repairSessionFileIfNeeded({ sessionFile: file, debug }); + const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.trimmedTrailingAssistantMessages).toBe(1); - expect(debug.mock.calls[0]?.[0]).toContain("trimmed 1 trailing assistant message(s)"); + expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(2); + const after = await fs.readFile(file, "utf-8"); + expect(after).toBe(original); }); - it("trims multiple consecutive trailing assistant messages", async () => { + it("preserves multiple consecutive delivered trailing assistant messages", async () => { const { file } = await createTempSessionPath(); const { header, message } = buildSessionHeaderAndMessage(); const assistantEntry1 = { @@ -371,12 +366,10 @@ describe("repairSessionFileIfNeeded", () => { const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.trimmedTrailingAssistantMessages).toBe(2); + expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(2); + const after = await fs.readFile(file, "utf-8"); + expect(after).toBe(original); }); it("does not trim non-trailing assistant messages", async () => { @@ -406,7 +399,6 @@ describe("repairSessionFileIfNeeded", () => { const result = await repairSessionFileIfNeeded({ sessionFile: file }); expect(result.repaired).toBe(false); - expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0); }); it("preserves trailing assistant messages that contain tool calls", async () => { @@ -432,12 +424,11 @@ describe("repairSessionFileIfNeeded", () => { const result = await repairSessionFileIfNeeded({ sessionFile: file }); expect(result.repaired).toBe(false); - expect(result.trimmedTrailingAssistantMessages ?? 0).toBe(0); const after = await fs.readFile(file, "utf-8"); expect(after).toBe(original); }); - it("trims non-tool-call assistant but stops at tool-call assistant", async () => { + it("preserves adjacent trailing tool-call and text assistant messages", async () => { const { file } = await createTempSessionPath(); const { header, message } = buildSessionHeaderAndMessage(); const toolCallAssistant = { @@ -467,16 +458,13 @@ describe("repairSessionFileIfNeeded", () => { const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.trimmedTrailingAssistantMessages).toBe(1); + expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(3); - expect(JSON.parse(repairedLines[2]).id).toBe("msg-asst-tc"); + const after = await fs.readFile(file, "utf-8"); + expect(after).toBe(original); }); - it("never trims below the session header", async () => { + it("preserves assistant-only session history after the header", async () => { const { file } = await createTempSessionPath(); const { header } = buildSessionHeaderAndMessage(); const assistantEntry = { @@ -495,13 +483,10 @@ describe("repairSessionFileIfNeeded", () => { const result = await repairSessionFileIfNeeded({ sessionFile: file }); - expect(result.repaired).toBe(true); - expect(result.trimmedTrailingAssistantMessages).toBe(1); + expect(result.repaired).toBe(false); - const repaired = await fs.readFile(file, "utf-8"); - const repairedLines = repaired.trim().split("\n"); - expect(repairedLines).toHaveLength(1); - expect(JSON.parse(repairedLines[0]).type).toBe("session"); + const after = await fs.readFile(file, "utf-8"); + expect(after).toBe(original); }); it("is a no-op on a session that was already repaired", async () => { diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index 1fff42046e5..ed72b13c9c1 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -10,9 +10,7 @@ type RepairReport = { repaired: boolean; droppedLines: number; rewrittenAssistantMessages?: number; - droppedBlankUserMessages?: number; rewrittenUserMessages?: number; - trimmedTrailingAssistantMessages?: number; backupPath?: string; reason?: string; }; @@ -68,10 +66,7 @@ function rewriteAssistantEntryWithEmptyContent(entry: SessionMessageEntry): Sess }; } -type UserEntryRepair = - | { kind: "drop" } - | { kind: "rewrite"; entry: SessionMessageEntry } - | { kind: "keep" }; +type UserEntryRepair = { kind: "rewrite"; entry: SessionMessageEntry } | { kind: "keep" }; function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEntryRepair { const content = entry.message.content; @@ -136,42 +131,10 @@ function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEn }; } -function isToolCallBlock(block: unknown): boolean { - if (!block || typeof block !== "object") { - return false; - } - const type = (block as { type?: unknown }).type; - return type === "toolCall" || type === "toolUse" || type === "functionCall"; -} - -/** Trailing assistant without tool calls — safe to trim from disk. - * Assistant turns with tool calls are kept so transcript repair can - * synthesize missing tool results (mirrors the outbound guard). */ -function isTrimmableTrailingAssistantEntry(entry: unknown): boolean { - if (!entry || typeof entry !== "object") { - return false; - } - const record = entry as { type?: unknown; message?: unknown }; - if (record.type !== "message" || !record.message || typeof record.message !== "object") { - return false; - } - const message = record.message as { role?: unknown; content?: unknown }; - if (message.role !== "assistant") { - return false; - } - const content = message.content; - if (Array.isArray(content) && content.some(isToolCallBlock)) { - return false; - } - return true; -} - function buildRepairSummaryParts(params: { droppedLines: number; rewrittenAssistantMessages: number; - droppedBlankUserMessages: number; rewrittenUserMessages: number; - trimmedTrailingAssistantMessages: number; }): string { const parts: string[] = []; if (params.droppedLines > 0) { @@ -180,15 +143,9 @@ function buildRepairSummaryParts(params: { if (params.rewrittenAssistantMessages > 0) { parts.push(`rewrote ${params.rewrittenAssistantMessages} assistant message(s)`); } - if (params.droppedBlankUserMessages > 0) { - parts.push(`dropped ${params.droppedBlankUserMessages} blank user message(s)`); - } if (params.rewrittenUserMessages > 0) { parts.push(`rewrote ${params.rewrittenUserMessages} user message(s)`); } - if (params.trimmedTrailingAssistantMessages > 0) { - parts.push(`trimmed ${params.trimmedTrailingAssistantMessages} trailing assistant message(s)`); - } return parts.length > 0 ? parts.join(", ") : "no changes"; } @@ -219,7 +176,6 @@ export async function repairSessionFileIfNeeded(params: { const entries: unknown[] = []; let droppedLines = 0; let rewrittenAssistantMessages = 0; - let droppedBlankUserMessages = 0; let rewrittenUserMessages = 0; for (const line of lines) { @@ -241,10 +197,6 @@ export async function repairSessionFileIfNeeded(params: { ((entry as { message: { role?: unknown } }).message?.role ?? undefined) === "user" ) { const repairedUser = repairUserEntryWithBlankTextContent(entry as SessionMessageEntry); - if (repairedUser.kind === "drop") { - droppedBlankUserMessages += 1; - continue; - } if (repairedUser.kind === "rewrite") { entries.push(repairedUser.entry); rewrittenUserMessages += 1; @@ -268,22 +220,7 @@ export async function repairSessionFileIfNeeded(params: { return { repaired: false, droppedLines, reason: "invalid session header" }; } - // Sessions ending on role=assistant cause Anthropic prefill 400s when - // thinking is enabled. The outbound path strips per-request, but leaving - // the file corrupted causes repeated reject cycles across restarts. - let trimmedTrailingAssistantMessages = 0; - while (entries.length > 1 && isTrimmableTrailingAssistantEntry(entries[entries.length - 1])) { - entries.pop(); - trimmedTrailingAssistantMessages += 1; - } - - if ( - droppedLines === 0 && - rewrittenAssistantMessages === 0 && - droppedBlankUserMessages === 0 && - rewrittenUserMessages === 0 && - trimmedTrailingAssistantMessages === 0 - ) { + if (droppedLines === 0 && rewrittenAssistantMessages === 0 && rewrittenUserMessages === 0) { return { repaired: false, droppedLines: 0 }; } @@ -315,9 +252,7 @@ export async function repairSessionFileIfNeeded(params: { repaired: false, droppedLines, rewrittenAssistantMessages, - droppedBlankUserMessages, rewrittenUserMessages, - trimmedTrailingAssistantMessages, reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`, }; } @@ -326,18 +261,14 @@ export async function repairSessionFileIfNeeded(params: { `session file repaired: ${buildRepairSummaryParts({ droppedLines, rewrittenAssistantMessages, - droppedBlankUserMessages, rewrittenUserMessages, - trimmedTrailingAssistantMessages, })} (${path.basename(sessionFile)})`, ); return { repaired: true, droppedLines, rewrittenAssistantMessages, - droppedBlankUserMessages, rewrittenUserMessages, - trimmedTrailingAssistantMessages, backupPath, }; }