mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
fix(agents): preserve repaired session assistant history
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user