Skip to content

Commit 79a28e5

Browse files
maweibinclaude
andcommitted
fix(sessions): prevent delivery-mirror prompt contamination with adjacent dedup and identity fallback (#99470)
Two-layer defence against delivery-mirror entries leaking into provider prompts after session rebuild / side-branch merge strips metadata: Layer 1 (identity fallback): isTranscriptOnlyOpenClawAssistantMessage gains an openclawDeliveryMirror field check to catch stripped-metadata survivors that lost provider/model during rebuild. Layer 2 (adjacent dedup): normalizeAssistantReplayContent collapses adjacent byte-identical no-tool-call assistant messages, catching fully-bare {role,content,usage} survivors invisible to any metadata filter. P1 fix: added 'tool_use' (native Anthropic snake_case) to TOOL_CALL_TYPES in extractToolCallsFromAssistant so adjacent dedup never collapses tool-call-bearing turns. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 61d8c3f commit 79a28e5

5 files changed

Lines changed: 554 additions & 8 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
* Real Behavior Proof for #99470 β€” delivery-mirror prompt contamination.
3+
*
4+
* Two-layer defence:
5+
* Layer 1 (identity fallback): isTranscriptOnlyOpenClawAssistantMessage now
6+
* checks openclawDeliveryMirror field for stripped-metadata survivors.
7+
* Layer 2 (adjacent dedup): normalizeAssistantReplayContent collapses
8+
* byte-identical adjacent no-tool assistant duplicates.
9+
*
10+
* This script exercises the full replay path: simulate a session rebuild
11+
* transcript β†’ run through normalizeAssistantReplayContent (called by both
12+
* sanitizeSessionHistory and normalizeMessagesForLlmBoundary) β†’ verify the
13+
* provider-bound prompt is clean.
14+
*
15+
* Usage: node --import tsx scripts/repro/issue-99470-proof.mts
16+
*/
17+
import { normalizeAssistantReplayContent } from "../../src/agents/embedded-agent-runner/replay-history.js";
18+
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../src/shared/transcript-only-openclaw-assistant.js";
19+
import type { AgentMessage } from "../../src/agents/runtime/index.js";
20+
21+
function user(text: string): AgentMessage {
22+
return { role: "user", content: text, timestamp: 0 } as unknown as AgentMessage;
23+
}
24+
25+
function assistant(content: unknown, extra?: Record<string, unknown>): AgentMessage {
26+
return {
27+
role: "assistant",
28+
content,
29+
usage: { input: 10, output: 20, totalTokens: 30 },
30+
stopReason: "stop",
31+
timestamp: 0,
32+
...extra,
33+
} as unknown as AgentMessage;
34+
}
35+
36+
function toolResult(toolUseId: string, text: string): AgentMessage {
37+
return {
38+
role: "user",
39+
content: [{ type: "tool_result", tool_use_id: toolUseId, content: text }],
40+
timestamp: 0,
41+
} as unknown as AgentMessage;
42+
}
43+
44+
function strippedMirror(text: string): AgentMessage {
45+
// Metadata-stripped delivery-mirror survivor (#99470): provider/model/usage
46+
// stripped by session rebuild / side-branch merge, but openclawDeliveryMirror
47+
// survives serialization.
48+
return assistant([{ type: "text", text }], {
49+
openclawDeliveryMirror: { kind: "channel-final" },
50+
usage: { input: 0, output: 0, totalTokens: 0 },
51+
});
52+
}
53+
54+
function liveMirror(text: string): AgentMessage {
55+
// Live delivery-mirror entry before metadata is stripped β€” has provider/model
56+
return assistant([{ type: "text", text }], {
57+
provider: "openclaw",
58+
model: "delivery-mirror",
59+
usage: { input: 0, output: 0, totalTokens: 0 },
60+
});
61+
}
62+
63+
function bareDuplicate(text: string, usage?: { input: number; output: number; totalTokens: number }): AgentMessage {
64+
// Truly marker-free duplicate (#99470 P1): after session rebuild/side-branch
65+
// merge, the delivery-mirror entry loses provider, model, AND
66+
// openclawDeliveryMirror β€” leaving only { role, content, usage }.
67+
// Layer 1 (identity fallback) CANNOT see this; only Layer 2 (adjacent dedup)
68+
// can collapse it.
69+
return assistant([{ type: "text", text }], {
70+
usage: usage ?? { input: 0, output: 0, totalTokens: 0 },
71+
});
72+
}
73+
74+
function nativeToolUseTurn(text: string, toolUseId: string, toolName: string): AgentMessage {
75+
return assistant(
76+
[
77+
{ type: "text", text },
78+
{ type: "tool_use", id: toolUseId, name: toolName, input: {} },
79+
],
80+
{ stopReason: "tool_use", usage: { input: 50, output: 30, totalTokens: 80 } },
81+
);
82+
}
83+
84+
let failures = 0;
85+
86+
function check(cond: boolean, label: string): void {
87+
if (cond) {
88+
console.log(` βœ“ ${label}`);
89+
} else {
90+
console.log(` βœ— FAIL: ${label}`);
91+
failures++;
92+
}
93+
}
94+
95+
// ══════════════════════════════════════════════════════════════════════════
96+
// Case 0 β€” Full Session Rebuild Contamination Path
97+
// ══════════════════════════════════════════════════════════════════════════
98+
// Simulates buildSessionContext() output after a session rebuild / side-branch
99+
// merge. Contains THREE types of delivery-mirror survivors that must all be
100+
// filtered before the provider sees them:
101+
// live β€” {provider:"openclaw", model:"delivery-mirror"} β€” Layer 1 (existing main filter)
102+
// stripped β€” {openclawDeliveryMirror:{...}} β€” Layer 1b (identity fallback, new)
103+
// bare β€” {role,content,usage} only, no marker at all β€” Layer 2 (adjacent dedup, new)
104+
// The bare duplicates are the P1 proof gap: without the marker, ONLY content-
105+
// based adjacent dedup can collapse them.
106+
console.log("═".repeat(64));
107+
console.log("Case 0 β€” Session rebuild contamination path (all 3 mirror types)");
108+
console.log("═".repeat(64));
109+
110+
const rebuildSessionContext: AgentMessage[] = [
111+
user("Find the error in the logs"),
112+
nativeToolUseTurn("Let me check the server logs.", "toolu_001", "read"),
113+
toolResult("toolu_001", "ERROR: connection timeout at 14:32:15"),
114+
assistant([{ type: "text", text: "I found a connection timeout error at 14:32:15. Let me investigate the cause." }]),
115+
liveMirror("I found a connection timeout error at 14:32:15. Let me investigate the cause."),
116+
strippedMirror("I found a connection timeout error at 14:32:15. Let me investigate the cause."),
117+
// P1 β€” truly marker-free duplicate: no provider, no model, no openclawDeliveryMirror.
118+
// Only Layer 2 (byte-identical adjacent content dedup) can remove this.
119+
bareDuplicate("I found a connection timeout error at 14:32:15. Let me investigate the cause."),
120+
user("What's the root cause?"),
121+
assistant([{ type: "text", text: "The connection pool was exhausted." }]),
122+
liveMirror("The connection pool was exhausted."),
123+
strippedMirror("The connection pool was exhausted."),
124+
bareDuplicate("The connection pool was exhausted."),
125+
];
126+
127+
console.log("\nBefore normalizeAssistantReplayContent (12 messages β€” 6 are mirrors):");
128+
rebuildSessionContext.forEach((m, i) => {
129+
const rec = m as Record<string, unknown>;
130+
const role = typeof rec.role === "string" ? rec.role : "";
131+
const dm = rec.openclawDeliveryMirror;
132+
const prov = rec.provider;
133+
const isLive = prov === "openclaw" && rec.model === "delivery-mirror";
134+
const isStripped = dm !== undefined && !isLive;
135+
// bare duplicates are at indices 6 and 11 β€” positioned adjacent to real replies
136+
const isBare = i === 6 || i === 11;
137+
const mirrorType = isLive ? "LIVE-MIRROR" : isStripped ? "STRIPPED-MIRROR" : isBare ? "BARE-DUP" : "";
138+
console.log(` [${i}] role=${role} ${mirrorType}`);
139+
});
140+
141+
const case0Out = normalizeAssistantReplayContent(rebuildSessionContext);
142+
143+
console.log("\nAfter normalizeAssistantReplayContent β†’ provider-bound prompt:");
144+
case0Out.forEach((m, i) => {
145+
const rec = m as Record<string, unknown>;
146+
const role = typeof rec.role === "string" ? rec.role : "";
147+
const content = rec.content;
148+
const text = Array.isArray(content) && content[0] && typeof content[0] === "object"
149+
? (content[0] as { text?: string }).text?.slice(0, 60) ?? ""
150+
: typeof content === "string"
151+
? content.slice(0, 60)
152+
: "";
153+
console.log(` [${i}] role=${role} text="${text}"`);
154+
});
155+
156+
console.log("\nAssertions:");
157+
check(case0Out.length === 6, `12β†’6 messages (6 delivery-mirror entries removed β€” live + stripped + bare)`);
158+
check(case0Out.every((m) => {
159+
const rec = m as Record<string, unknown>;
160+
const dm = rec.openclawDeliveryMirror;
161+
const prov = rec.provider;
162+
return !((prov === "openclaw" && rec.model === "delivery-mirror") || dm !== undefined);
163+
}), "No live-mirror or stripped-mirror entries in provider-bound output");
164+
check(case0Out.some((m) => {
165+
const rec = m as Record<string, unknown>;
166+
const content = rec.content;
167+
return Array.isArray(content) && content.some((b: Record<string, unknown>) => b.type === "tool_use");
168+
}), "Tool-use turn preserved (not collapsed by dedup)");
169+
170+
// ══════════════════════════════════════════════════════════════════════════
171+
// Case 1 β€” Identity fallback (openclawDeliveryMirror field check)
172+
// ══════════════════════════════════════════════════════════════════════════
173+
console.log("\n" + "═".repeat(64));
174+
console.log("Case 1 β€” openclawDeliveryMirror identity fallback");
175+
console.log("═".repeat(64));
176+
177+
const stripped = strippedMirror("channel mirror text");
178+
check(
179+
isTranscriptOnlyOpenClawAssistantMessage(stripped),
180+
"Stripped-metadata survivor β†’ isTranscriptOnly=true (Layer 1 catches it)",
181+
);
182+
183+
const normal = assistant([{ type: "text", text: "real reply" }]);
184+
check(
185+
!isTranscriptOnlyOpenClawAssistantMessage(normal),
186+
"Normal assistant reply β†’ isTranscriptOnly=false (correctly survives filter)",
187+
);
188+
189+
// ══════════════════════════════════════════════════════════════════════════
190+
// Case 2 β€” Adjacent bare duplicate collapse (Layer 2 only, no marker)
191+
// ══════════════════════════════════════════════════════════════════════════
192+
// Uses bareDuplicate: NO openclawDeliveryMirror marker. Layer 1 (identity
193+
// fallback) CANNOT see this entry β€” it has no provider, model, or mirror
194+
// marker. Only Layer 2 (byte-identical adjacent content dedup) can collapse it.
195+
console.log("\n" + "═".repeat(64));
196+
console.log("Case 2 β€” Adjacent bare duplicate collapse (Layer 2 only, no marker)");
197+
console.log("═".repeat(64));
198+
199+
const case2Out = normalizeAssistantReplayContent([
200+
user("hi"),
201+
assistant([{ type: "text", text: "Hello! How can I help?" }]),
202+
bareDuplicate("Hello! How can I help?"), // no marker at all β€” only Layer 2 sees this
203+
user("thanks"),
204+
]);
205+
206+
check(case2Out.length === 3, "4β†’3 messages (stripped-mirror duplicate collapsed)");
207+
check(
208+
(case2Out[1] as Record<string, unknown>).role === "assistant",
209+
"Real assistant reply preserved at [1]",
210+
);
211+
212+
// ══════════════════════════════════════════════════════════════════════════
213+
// Case 3 β€” Both layers: identity fallback + adjacent dedup
214+
// ══════════════════════════════════════════════════════════════════════════
215+
console.log("\n" + "═".repeat(64));
216+
console.log("Case 3 β€” Both layers together in one pass");
217+
console.log("═".repeat(64));
218+
219+
const case3Out = normalizeAssistantReplayContent([
220+
user("hi"),
221+
assistant([{ type: "text", text: "Hello! How can I help?" }]),
222+
strippedMirror("Different text β€” caught by identity fallback"),
223+
assistant([{ type: "text", text: "Hello! How can I help?" }]),
224+
strippedMirror("Hello! How can I help?"),
225+
user("thanks"),
226+
]);
227+
228+
check(case3Out.length === 3, "6β†’3 messages (both layers active)");
229+
check(
230+
case3Out.every((m) => !isTranscriptOnlyOpenClawAssistantMessage(m)),
231+
"No transcript-only messages in output",
232+
);
233+
234+
// ══════════════════════════════════════════════════════════════════════════
235+
// Case 4 β€” Native tool_use blocks prevent collapse (P1 fix)
236+
// ══════════════════════════════════════════════════════════════════════════
237+
console.log("\n" + "═".repeat(64));
238+
console.log("Case 4 β€” Native tool_use blocks prevent collapse (P1 fix)");
239+
console.log("═".repeat(64));
240+
241+
const case4Out = normalizeAssistantReplayContent([
242+
user("check the logs"),
243+
nativeToolUseTurn("Let me check that for you.", "toolu_001", "read"),
244+
assistant([{ type: "text", text: "Let me check that for you." }]),
245+
]);
246+
247+
check(case4Out.length === 3, "3β†’3 messages (tool_use turn preserved, text-only turn also kept)");
248+
const hasToolUse =
249+
Array.isArray((case4Out[1] as { content: unknown }).content) &&
250+
((case4Out[1] as { content: Array<{ type: string }> }).content.some(
251+
(b) => b.type === "tool_use",
252+
) ?? false);
253+
check(hasToolUse, "Native tool_use block present at [1]");
254+
255+
// ══════════════════════════════════════════════════════════════════════════
256+
// Case 5 β€” Native tool_use in BOTH prev and next
257+
// ══════════════════════════════════════════════════════════════════════════
258+
console.log("\n" + "═".repeat(64));
259+
console.log("Case 5 β€” Native tool_use in both prev and next prevents collapse");
260+
console.log("═".repeat(64));
261+
262+
const case5Out = normalizeAssistantReplayContent([
263+
user("check logs and status"),
264+
nativeToolUseTurn("Checking...", "toolu_010", "read"),
265+
nativeToolUseTurn("Checking...", "toolu_011", "status"),
266+
]);
267+
268+
check(case5Out.length === 3, "3β†’3 messages (both tool_use turns survive)");
269+
270+
// ══════════════════════════════════════════════════════════════════════════
271+
// Summary
272+
// ══════════════════════════════════════════════════════════════════════════
273+
console.log("\n" + "═".repeat(64));
274+
console.log("SUMMARY");
275+
console.log("═".repeat(64));
276+
277+
if (failures === 0) {
278+
console.log("\nβœ“ ALL PROOF CASES PASSED β€” provider-bound prompt is clean");
279+
process.exit(0);
280+
} else {
281+
console.log(`\nβœ— ${failures} ASSERTION(S) FAILED`);
282+
process.exit(1);
283+
}

0 commit comments

Comments
Β (0)