Preserve delivered assistant replies in session repair (#76420)

Summary:
- The PR removes session-file repair's trailing-assistant disk trim, updates regression coverage, clarifies transcript hygiene docs, and adds a changelog entry for the Telegram/WebChat history loss fix.
- Reproducibility: yes. Current main has a clear source path: a normal trailing assistant JSONL record is popped by `repairSessionFileIfNeeded`, and the main-branch test suite asserts that deletion.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs(agents): clarify session repair preservation
- PR branch already contained follow-up commit before automerge: fix(agents): preserve delivered assistant replies in session repair

Validation:
- ClawSweeper review passed for head 66c187fd76.
- Required merge gates passed before the squash merge.

Prepared head SHA: 66c187fd76
Review: https://github.com/openclaw/openclaw/pull/76420#issuecomment-4365323320

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Ayaan Zaidi
2026-05-03 09:34:11 +05:30
committed by GitHub
parent 6a3f5d0b1f
commit e8756d99ae
4 changed files with 23 additions and 86 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- CLI/plugins: reject missing plugin ids before config writes in `plugins enable` and `plugins disable` so a typo no longer persists a stale config entry. (#73554) Thanks @ai-hpc.
- Agents/sessions: preserve delivered trailing assistant replies during session-file repair so Telegram/WebChat history is not rewritten to drop already-delivered responses. Fixes #76329. Thanks @obviyus.
- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206.
- Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev.
- Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79.

View File

@@ -7,9 +7,7 @@ read_when:
title: "Transcript hygiene"
---
OpenClaw applies **provider-specific fixes** to transcripts before a run (building model context). Most of these are **in-memory** adjustments used to satisfy strict provider requirements. A separate session-file repair pass may also rewrite stored JSONL before the session is loaded, either by dropping malformed JSONL lines or by repairing persisted turns that are syntactically valid but known to be rejected by a
provider during replay. When a repair occurs, the original file is backed up alongside
the session file.
OpenClaw applies **provider-specific fixes** to transcripts before a run (building model context). Most of these are **in-memory** adjustments used to satisfy strict provider requirements. A separate session-file repair pass may also rewrite stored JSONL before the session is loaded, but only for malformed lines or persisted turns that are invalid durable records. Delivered assistant replies are preserved on disk; provider-specific assistant-prefill stripping happens only while constructing outbound payloads. When a repair occurs, the original file is backed up alongside the session file.
Scope includes:

View File

@@ -117,7 +117,7 @@ describe("repairSessionFileIfNeeded", () => {
errorMessage: "transient stream failure",
},
};
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
// Follow-up keeps this case focused on empty error-turn repair.
const followUp = {
type: "message",
id: "msg-3",
@@ -293,7 +293,7 @@ describe("repairSessionFileIfNeeded", () => {
stopReason: "stop",
},
};
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
// Follow-up keeps this case focused on silent-reply preservation.
const followUp = {
type: "message",
id: "msg-3",
@@ -312,7 +312,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 +329,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 +367,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 +400,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 +425,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 +459,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 +484,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 () => {
@@ -522,7 +508,7 @@ describe("repairSessionFileIfNeeded", () => {
stopReason: "error",
},
};
// Follow-up so the session doesn't end on assistant (trailing-trim is tested separately).
// Follow-up keeps this case focused on idempotent empty error-turn repair.
const followUp = {
type: "message",
id: "msg-3",

View File

@@ -12,7 +12,6 @@ type RepairReport = {
rewrittenAssistantMessages?: number;
droppedBlankUserMessages?: number;
rewrittenUserMessages?: number;
trimmedTrailingAssistantMessages?: number;
backupPath?: string;
reason?: string;
};
@@ -136,42 +135,11 @@ 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) {
@@ -186,9 +154,6 @@ function buildRepairSummaryParts(params: {
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";
}
@@ -268,21 +233,11 @@ 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
rewrittenUserMessages === 0
) {
return { repaired: false, droppedLines: 0 };
}
@@ -317,7 +272,6 @@ export async function repairSessionFileIfNeeded(params: {
rewrittenAssistantMessages,
droppedBlankUserMessages,
rewrittenUserMessages,
trimmedTrailingAssistantMessages,
reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`,
};
}
@@ -328,7 +282,6 @@ export async function repairSessionFileIfNeeded(params: {
rewrittenAssistantMessages,
droppedBlankUserMessages,
rewrittenUserMessages,
trimmedTrailingAssistantMessages,
})} (${path.basename(sessionFile)})`,
);
return {
@@ -337,7 +290,6 @@ export async function repairSessionFileIfNeeded(params: {
rewrittenAssistantMessages,
droppedBlankUserMessages,
rewrittenUserMessages,
trimmedTrailingAssistantMessages,
backupPath,
};
}