From b1a228fc3a5080373b6751169a6a4b80a2beb804 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 12 Apr 2026 04:33:04 +0100 Subject: [PATCH] test: cover replay scope and attachment redaction guards --- ...ed-runner.sanitize-session-history.test.ts | 27 ++++ .../pi-embedded-runner/run/attempt.test.ts | 115 ++++++++++++++++++ ...sion-transcript-repair.attachments.test.ts | 46 +++++++ src/agents/session-transcript-repair.test.ts | 41 ++++++- 4 files changed, 227 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index c0b6361e1d7..8c9757cadff 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -968,6 +968,33 @@ describe("sanitizeSessionHistory", () => { ]); }); + it("keeps mutable thinking turns outside exact anthropic replay", async () => { + setNonGoogleModelApi(); + + const messages = castAgentMessages([ + makeUserMessage("read a file"), + makeAssistantMessage([ + { + type: "thinking", + thinking: "I should use the read tool", + thinkingSignature: "reasoning_text", + }, + { type: "toolCall", id: "tool_123", name: " read ", arguments: { path: "/tmp/test" } }, + ]), + ]); + + const result = await sanitizeGithubCopilotHistory({ messages }); + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([ + { + type: "thinking", + thinking: "I should use the read tool", + thinkingSignature: "reasoning_text", + }, + { type: "toolCall", id: "tool_123", name: "read", arguments: { path: "/tmp/test" } }, + ]); + }); + it("keeps the earlier anthropic replay prefix stable after a later subagent turn", async () => { setNonGoogleModelApi(); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 9f985879716..dbe9e035fed 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1043,6 +1043,121 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => { ]); }); + it("drops signed thinking turns when replay would expose non-content attachment payload fields", async () => { + const attachmentContent = "SIGNED_THINKING_NESTED_ATTACHMENT"; + const messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { + type: "toolUse", + id: "call_1", + name: "sessions_spawn", + input: { + task: "inspect attachment", + attachments: [ + { + name: "snapshot.txt", + mimeType: "text/plain", + data: attachmentContent, + }, + ], + }, + }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["sessions_spawn"]), + { validateAnthropicTurns: true } as never, + ); + const stream = wrapped( + { api: "anthropic-messages" } as never, + { messages } as never, + {} as never, + ) as FakeWrappedStream | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + + it("keeps mutable thinking turns outside anthropic replay-only preservation", async () => { + const messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { type: "toolCall", id: "call_1", name: " read ", arguments: {} }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]; + const baseFn = vi.fn((_model, _context) => + createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }), + ); + + const wrapped = wrapStreamFnSanitizeMalformedToolCalls( + baseFn as never, + new Set(["read"]), + { validateAnthropicTurns: true } as never, + ); + const stream = wrapped( + { api: "openai-completions" } as never, + { messages } as never, + {} as never, + ) as FakeWrappedStream | Promise; + await Promise.resolve(stream); + + expect(baseFn).toHaveBeenCalledTimes(1); + const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] }; + expect(seenContext.messages).toEqual([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal", thinkingSignature: "sig_1" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [ + { + type: "text", + text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.", + }, + ], + isError: true, + timestamp: expect.any(Number), + }, + { + role: "user", + content: [{ type: "text", text: "retry" }], + }, + ]); + }); + it("preserves sessions_spawn attachment payloads on replay", async () => { const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD"; const messages = [ diff --git a/src/agents/session-transcript-repair.attachments.test.ts b/src/agents/session-transcript-repair.attachments.test.ts index 467fc6f3e6c..da89caf9c91 100644 --- a/src/agents/session-transcript-repair.attachments.test.ts +++ b/src/agents/session-transcript-repair.attachments.test.ts @@ -74,4 +74,50 @@ describe("sanitizeToolCallInputs redacts sessions_spawn attachments", () => { ).toBe("__OPENCLAW_REDACTED__"); expect(JSON.stringify(out)).not.toContain(secret); }); + + it("replaces non-content attachment payload fields with a minimal redacted stub", () => { + const secret = "NESTED_ATTACHMENT_SECRET"; // pragma: allowlist secret + const input = castAgentMessages([ + { + role: "assistant", + content: [ + { + type: "toolUse", + id: "call_3", + name: "sessions_spawn", + input: { + task: "do thing", + attachments: [ + { + name: "payload.json", + mimeType: "application/json", + encoding: "utf8", + data: secret, + nested: { secret }, + }, + ], + }, + }, + ], + }, + ]); + + const out = sanitizeToolCallInputs(input); + const msg = out[0] as { content?: unknown[] }; + const tool = (msg.content?.[0] ?? null) as { + input?: { attachments?: unknown[] }; + arguments?: { attachments?: unknown[] }; + } | null; + const attachment = + (tool?.input?.attachments?.[0] ?? tool?.arguments?.attachments?.[0] ?? null) as + | Record + | null; + expect(attachment).toEqual({ + name: "payload.json", + mimeType: "application/json", + encoding: "utf8", + content: "__OPENCLAW_REDACTED__", + }); + expect(JSON.stringify(out)).not.toContain(secret); + }); }); diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index dcb36dba379..bffcc328083 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -405,7 +405,7 @@ describe("sanitizeToolCallInputs", () => { const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"], - preserveImmutableThinkingTurns: true, + allowProviderOwnedThinkingReplay: true, }); expect(out).toEqual([]); @@ -437,13 +437,50 @@ describe("sanitizeToolCallInputs", () => { const out = sanitizeToolCallInputs(input, { allowedToolNames: ["sessions_spawn"], - preserveImmutableThinkingTurns: true, + allowProviderOwnedThinkingReplay: true, }); expect(out).toEqual([]); expect(JSON.stringify(out)).not.toContain(secret); }); + it("keeps signed-thinking assistant turns when sessions_spawn attachments are already redacted", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Let me replay the helper turn.", + thinkingSignature: "sig_spawn_safe", + }, + { + type: "toolUse", + id: "call_spawn", + name: "sessions_spawn", + input: { + task: "inspect attachment", + attachments: [ + { + name: "snapshot.txt", + mimeType: "text/plain", + content: "__OPENCLAW_REDACTED__", + }, + ], + }, + }, + ], + }, + ]); + + const out = sanitizeToolCallInputs(input, { + allowedToolNames: ["sessions_spawn"], + allowProviderOwnedThinkingReplay: true, + }); + + expect(out).toEqual(input); + }); + it("keeps generic thinking turns mutable when immutable preservation is disabled", () => { const input = castAgentMessages([ {