From 740578b596f12778bfa58f5942a9742c47e108cb Mon Sep 17 00:00:00 2001 From: maweibin Date: Tue, 23 Jun 2026 17:25:45 +0800 Subject: [PATCH] fix: assistant reply lost between compaction summary and first kept user in successor transcript (#95484) Merged via squash. Prepared head SHA: eff5894fb81988ee81bdc9c0092a8e8e67dd0b8f Co-authored-by: maweibin <18023423+maweibin@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- ...-76729-compaction-assistant-loss-proof.mts | 81 +++++++++++++++++ .../compaction-successor-transcript.test.ts | 50 +++++++++++ .../compaction-successor-transcript.ts | 90 +++++++++++++++++-- 3 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 scripts/repro/issue-76729-compaction-assistant-loss-proof.mts diff --git a/scripts/repro/issue-76729-compaction-assistant-loss-proof.mts b/scripts/repro/issue-76729-compaction-assistant-loss-proof.mts new file mode 100644 index 00000000000..5435de30ba2 --- /dev/null +++ b/scripts/repro/issue-76729-compaction-assistant-loss-proof.mts @@ -0,0 +1,81 @@ +/** + * Live proof script for PR #95484 — assistant reply lost after compaction rotation. + * + * Demonstrates that: + * 1. BEFORE fix: successor context shows [compactionSummary, user, ...] + * — the assistant reply is silently dropped. + * 2. AFTER fix: successor context shows [compactionSummary, assistant, user, ...] + * — the assistant reply is preserved. + * + * Usage: node --import tsx scripts/repro/issue-76729-compaction-assistant-loss-proof.mts + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { TextContent } from "openclaw/plugin-sdk/llm"; +import { makeAgentAssistantMessage } from "../../src/agents/test-helpers/agent-message-fixtures.js"; +import { rotateTranscriptAfterCompaction } from "../../src/agents/embedded-agent-runner/compaction-successor-transcript.js"; + +const sessionDir = mkdtempSync(join(tmpdir(), "compaction-proof-")); +console.log("Session dir:", sessionDir); +console.log(); + +const manager = SessionManager.create(sessionDir, sessionDir); + +// Build session: user("Summarize") → assistant("Here is the summary") → user("Analyze Q3") → assistant("Q3 analysis...") → compaction(firstKept=user_Analyze_Q3) +manager.appendMessage({ role: "user", content: "Summarize reports", timestamp: 1 }); +manager.appendMessage(makeAgentAssistantMessage({ content: [{ type: "text", text: "Here is the summary" }], timestamp: 2 })); +const firstKeptId = manager.appendMessage({ role: "user", content: "Analyze Q3", timestamp: 3 }); +manager.appendMessage(makeAgentAssistantMessage({ content: [{ type: "text", text: "Q3 analysis shows..." }], timestamp: 4 })); +manager.appendCompaction("Summary of previous work.", firstKeptId, 5000); + +// Post-compaction +manager.appendMessage({ role: "user", content: "Any more insights?", timestamp: 5 }); +manager.appendMessage(makeAgentAssistantMessage({ content: [{ type: "text", text: "Additional insights" }], timestamp: 6 })); + +const sessionFile = manager.getSessionFile()!; +console.log("Source session file:", sessionFile); +console.log(); + +const result = await rotateTranscriptAfterCompaction({ + sessionManager: manager, + sessionFile, + now: () => new Date("2026-06-21T12:00:00.000Z"), +}); + +console.log("Rotation result:", JSON.stringify(result, null, 2)); +console.log(); + +// Open successor and inspect context +const successor = SessionManager.open(result.sessionFile!); +const context = successor.buildSessionContext(); + +console.log("=== SUCCESSOR CONTEXT ==="); +console.log("Roles:", JSON.stringify(context.messages.map((m) => m.role))); + +for (const msg of context.messages) { + if (msg.role === "compactionSummary") { + const summary = (msg as AgentMessage & { summary: string }).summary; + console.log(` [compactionSummary] summary="${summary}"`); + } else if ("content" in msg) { + const text = Array.isArray(msg.content) + ? (msg.content[0] as TextContent)?.text ?? JSON.stringify(msg.content) + : msg.content; + console.log(` [${msg.role}] "${text}"`); + } +} +console.log(); + +const roles = context.messages.map((m) => m.role); +console.log("=== VERIFICATION ==="); +console.log("Role sequence:", JSON.stringify(roles)); +console.log("compactionSummary → assistant (no gap):", roles[0] === "compactionSummary" && roles[1] === "assistant"); +console.log("BEFORE fix shows: [compactionSummary, user, assistant, ...] ← missing assistant"); +console.log("AFTER fix shows:", JSON.stringify(roles)); +console.log(); + +// Cleanup +rmSync(sessionDir, { recursive: true, force: true }); +console.log("Temp dir cleaned up."); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts index 47fe5d6b648..5d3481ef8a6 100644 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.test.ts @@ -164,6 +164,13 @@ describe("rotateTranscriptAfterCompaction", () => { summary: "Summary of old user and old assistant.", tokensBefore: 5000, }, + // The last assistant reply before firstKeptEntryId is preserved so the + // successor shows compactionSummary → assistant → user (issue #76729). + { + role: "assistant", + content: [{ type: "text", text: "old assistant" }], + timestamp: 2, + }, { role: "user", content: "kept user", timestamp: 3 }, { role: "assistant", @@ -179,6 +186,49 @@ describe("rotateTranscriptAfterCompaction", () => { ]); }); + it("keeps the paired tool result without replaying summarized custom context", async () => { + const dir = await createTmpDir(); + const manager = SessionManager.create(dir, dir); + manager.appendMessage({ role: "user", content: "read the file", timestamp: 1 }); + manager.appendMessage( + makeAgentAssistantMessage({ + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + timestamp: 2, + }), + ); + manager.appendMessage({ + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + timestamp: 3, + }); + manager.appendCustomMessageEntry("test", "summarized custom context", false); + const firstKeptId = manager.appendMessage({ + role: "user", + content: "continue", + timestamp: 4, + }); + manager.appendMessage(makeAssistant("done", 5)); + manager.appendCompaction("Summary of the read.", firstKeptId, 5000); + manager.appendMessage({ role: "user", content: "next", timestamp: 6 }); + + const result = await rotateTranscriptAfterCompaction({ + sessionManager: manager, + sessionFile: requireString(manager.getSessionFile(), "session file"), + }); + const successor = SessionManager.open(requireString(result.sessionFile, "successor file")); + const messages = successor.buildSessionContext().messages; + const assistant = messages.find((message) => message.role === "assistant"); + const toolResult = messages.find((message) => message.role === "toolResult"); + + expect(assistant?.role).toBe("assistant"); + expect(toolResult?.role).toBe("toolResult"); + expect(JSON.stringify(messages)).toContain("file contents"); + expect(JSON.stringify(messages)).not.toContain("summarized custom context"); + }); + it("creates a compacted successor transcript and leaves the archive untouched", async () => { const dir = await createTmpDir(); const { manager, sessionFile, firstKeptId, oldUserId } = createCompactedSession(dir); diff --git a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts index c3a53cbea0f..6ced8929493 100644 --- a/src/agents/embedded-agent-runner/compaction-successor-transcript.ts +++ b/src/agents/embedded-agent-runner/compaction-successor-transcript.ts @@ -134,6 +134,60 @@ function buildSuccessorEntries(params: { } } + // Preserve the last assistant message before firstKeptEntryId so the successor + // transcript keeps a compactionSummary → assistant → user structure instead of + // dropping the assistant reply (issue #76729). + // Skip for hardened manual compaction boundaries where firstKeptEntryId already + // points to the compaction entry itself (all pre-compaction content is + // intentionally excluded). + const isHardenedBoundary = compaction.firstKeptEntryId === compaction.id; + let preservedAssistantId: string | undefined; + let preservedAssistantIndex = -1; + let firstKeptIndex = -1; + if (!isHardenedBoundary) { + for (let index = latestCompactionIndex - 1; index >= 0; index -= 1) { + const entry = branch[index]; + if ( + entry && + summarizedBranchIds.has(entry.id) && + entry.type === "message" && + entry.message.role === "assistant" + ) { + preservedAssistantId = entry.id; + preservedAssistantIndex = index; + break; + } + } + } + if (compaction.firstKeptEntryId) { + firstKeptIndex = branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId); + } + const branchIndexById = new Map(branch.map((entry, index) => [entry.id, index])); + const preservedPreCompactionIds = new Set(); + if (preservedAssistantId) { + preservedPreCompactionIds.add(preservedAssistantId); + const assistant = branch[preservedAssistantIndex]; + if (assistant?.type === "message" && assistant.message.role === "assistant") { + const toolCallIds = new Set( + assistant.message.content + .filter((block) => block.type === "toolCall") + .map((block) => block.id), + ); + for ( + let index = preservedAssistantIndex + 1; + index >= 0 && index < firstKeptIndex; + index += 1 + ) { + const entry = branch[index]; + if (entry?.type === "message" && entry.message.role === "toolResult") { + if (toolCallIds.has(entry.message.toolCallId)) { + preservedPreCompactionIds.add(entry.id); + } + } + } + } + } + const latestStateEntryIds = collectLatestStateEntryIds(branch.slice(0, latestCompactionIndex)); const staleStateEntryIds = new Set(); for (const entry of branch.slice(0, latestCompactionIndex)) { @@ -152,14 +206,27 @@ function buildSuccessorEntries(params: { ...collectDuplicateUserMessageEntryIdsForCompaction(postCompactionEntries), ]); for (const entry of allEntries) { + const branchIndex = branchIndexById.get(entry.id) ?? -1; + const summarizedContextMarker = + branchIndex > preservedAssistantIndex && + branchIndex < firstKeptIndex && + (entry.type === "custom_message" || entry.type === "branch_summary"); if ( - (summarizedBranchIds.has(entry.id) && entry.type === "message") || + (summarizedBranchIds.has(entry.id) && + entry.type === "message" && + !preservedPreCompactionIds.has(entry.id)) || + (summarizedBranchIds.has(entry.id) && summarizedContextMarker) || staleStateEntryIds.has(entry.id) || duplicateUserMessageIds.has(entry.id) ) { removedIds.add(entry.id); } } + // The preserved assistant reply is pre-compaction content that needs thinking + // signature stripping, same as other pre-compaction kept entries. + for (const entryId of preservedPreCompactionIds) { + preCompactionKeptBranchIds.add(entryId); + } for (const entry of allEntries) { if (entry.type === "label" && removedIds.has(entry.targetId)) { removedIds.add(entry.id); @@ -194,10 +261,23 @@ function buildSuccessorEntries(params: { // signatures are bound to the original context prefix; the successor file has a different // prefix so those signatures would cause Anthropic "Invalid signature in thinking block". // Post-compaction entries were generated in the new context and have valid signatures. - const transformed = - reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id) - ? { ...reparented, message: stripThinkingSignaturesFromMessage(reparented.message) } - : reparented; + // Move the compaction boundary back to the preserved turn so its complete + // assistant/tool-result sequence is included in the successor context. + let transformed: SessionEntry = reparented; + if (reparented.type === "message" && preCompactionKeptBranchIds.has(reparented.id)) { + transformed = { + ...reparented, + message: stripThinkingSignaturesFromMessage(reparented.message), + }; + } + if ( + reparented.type === "compaction" && + reparented.id === compaction.id && + preservedAssistantId && + reparented.firstKeptEntryId !== reparented.id + ) { + transformed = { ...reparented, firstKeptEntryId: preservedAssistantId }; + } keptEntries.push(transformed); }