fix(agents): preserve repaired session assistant history

This commit is contained in:
Ayaan Zaidi
2026-05-03 09:07:23 +05:30
committed by clawsweeper
parent 6a3f5d0b1f
commit fe71e0f4c5
2 changed files with 19 additions and 103 deletions

View File

@@ -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 () => {

View File

@@ -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,
};
}