|
| 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