mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
0a986f893a
commit
740578b596
3 changed files with 216 additions and 5 deletions
|
|
@ -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.");
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue