fix: assistant reply lost between compaction summary and first kept user in successor transcript (#95484)

Merged via squash.

Prepared head SHA: eff5894fb8
Co-authored-by: maweibin <18023423+maweibin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
maweibin 2026-06-23 17:25:45 +08:00 committed by GitHub
parent 0a986f893a
commit 740578b596
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 216 additions and 5 deletions

View file

@ -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.");

View file

@ -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);

View file

@ -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<string>();
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<string>();
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);
}