import type { AgentMessage } from "@mariozechner/pi-agent-core" ;
import { describe, expect, it } from "vitest" ;
import {
sanitizeToolCallInputs,
sanitizeToolUseResultPairing,
repairToolUseResultPairing,
stripToolResultDetails,
} from "./session-transcript-repair.js" ;
import { castAgentMessage, castAgentMessages } from "./test-helpers/agent-message-fixtures.js" ;
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall" , "toolUse" , "functionCall" ]);
function getAssistantToolCallBlocks(messages: AgentMessage[]) {
const assistant = messages[0 ] as Extract<AgentMessage, { role: "assistant" }> | undefined;
if (!assistant || !Array.isArray(assistant.content)) {
return [] as Array<{ type?: unknown; id?: unknown; name?: unknown }>;
}
return assistant.content.filter((block) => {
const type = (block as { type?: unknown }).type;
return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type);
}) as Array<{ type?: unknown; id?: unknown; name?: unknown }>;
}
describe("sanitizeToolUseResultPairing" , () => {
const buildDuplicateToolResultInput = (opts?: {
middleMessage?: unknown;
secondText?: string;
}): AgentMessage[] =>
castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_1" , name: "read" , arguments: {} }],
},
{
role: "toolResult" ,
toolCallId: "call_1" ,
toolName: "read" ,
content: [{ type: "text" , text: "first" }],
isError: false ,
},
...(opts?.middleMessage ? [castAgentMessage(opts.middleMessage)] : []),
{
role: "toolResult" ,
toolCallId: "call_1" ,
toolName: "read" ,
content: [{ type: "text" , text: opts?.secondText ?? "second" }],
isError: false ,
},
]);
it("moves tool results directly after tool calls and inserts missing results" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{ type: "toolCall" , id: "call_1" , name: "read" , arguments: {} },
{ type: "toolCall" , id: "call_2" , name: "exec" , arguments: {} },
],
},
{ role: "user" , content: "user message that should come after tool use" },
{
role: "toolResult" ,
toolCallId: "call_2" ,
toolName: "exec" ,
content: [{ type: "text" , text: "ok" }],
isError: false ,
},
]);
const out = sanitizeToolUseResultPairing(input);
expect(out[0 ]?.role).toBe("assistant" );
expect(out[1 ]?.role).toBe("toolResult" );
expect((out[1 ] as { toolCallId?: string }).toolCallId).toBe("call_1" );
expect(out[2 ]?.role).toBe("toolResult" );
expect((out[2 ] as { toolCallId?: string }).toolCallId).toBe("call_2" );
expect(out[3 ]?.role).toBe("user" );
});
it("uses custom text for synthesized missing tool results" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_1" , name: "read" , arguments: {} }],
},
{ role: "user" , content: "user message that should come after tool use" },
]);
const result = repairToolUseResultPairing(input, {
missingToolResultText: "aborted" ,
});
expect(result.added).toHaveLength(1 );
expect(result.messages.map((m) => m.role)).toEqual(["assistant" , "toolResult" , "user" ]);
expect(result.added[0 ]?.content).toEqual([{ type: "text" , text: "aborted" }]);
});
it("keeps matched parallel tool results and synthesizes only missing siblings" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{ type: "text" , text: "checking" },
{ type: "toolCall" , id: "call_1" , name: "read" , arguments: {} },
{ type: "toolCall" , id: "call_2" , name: "exec" , arguments: {} },
{ type: "toolCall" , id: "call_3" , name: "write" , arguments: {} },
],
},
{ role: "user" , content: "user message that should come after tool use" },
{
role: "toolResult" ,
toolCallId: "call_2" ,
toolName: "exec" ,
content: [{ type: "text" , text: "ok" }],
isError: false ,
},
]);
const result = repairToolUseResultPairing(input, {
missingToolResultText: "aborted" ,
});
expect(result.added.map((message) => message.toolCallId)).toEqual(["call_1" , "call_3" ]);
expect(result.messages.map((m) => m.role)).toEqual([
"assistant" ,
"toolResult" ,
"toolResult" ,
"toolResult" ,
"user" ,
]);
expect(getAssistantToolCallBlocks(result.messages)).toMatchObject([
{ id: "call_1" , name: "read" },
{ id: "call_2" , name: "exec" },
{ id: "call_3" , name: "write" },
]);
expect((result.messages[1 ] as { toolCallId?: string }).toolCallId).toBe("call_1" );
expect((result.messages[2 ] as { toolCallId?: string }).toolCallId).toBe("call_2" );
expect((result.messages[3 ] as { toolCallId?: string }).toolCallId).toBe("call_3" );
expect(JSON.stringify(result.added)).not.toContain("missing tool result" );
});
it("repairs blank tool result names from matching tool calls" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_1" , name: "read" , arguments: {} }],
},
{
role: "toolResult" ,
toolCallId: "call_1" ,
toolName: " " ,
content: [{ type: "text" , text: "ok" }],
isError: false ,
},
]);
const out = sanitizeToolUseResultPairing(input);
const toolResult = out.find((message) => message.role === "toolResult" ) as {
toolName?: string;
};
expect(toolResult?.toolName).toBe("read" );
});
it("drops duplicate tool results for the same id within a span" , () => {
const input = castAgentMessages([
...buildDuplicateToolResultInput(),
{ role: "user" , content: "ok" },
]);
const out = sanitizeToolUseResultPairing(input);
expect(out.filter((m) => m.role === "toolResult" )).toHaveLength(1 );
});
it("drops duplicate tool results for the same id across the transcript" , () => {
const input = buildDuplicateToolResultInput({
middleMessage: { role: "assistant" , content: [{ type: "text" , text: "ok" }] },
secondText: "second (duplicate)" ,
});
const out = sanitizeToolUseResultPairing(input);
const results = out.filter((m) => m.role === "toolResult" ) as Array<{
toolCallId?: string;
}>;
expect(results).toHaveLength(1 );
expect(results[0 ]?.toolCallId).toBe("call_1" );
});
it("drops orphan tool results that do not match any tool call" , () => {
const input = castAgentMessages([
{ role: "user" , content: "hello" },
{
role: "toolResult" ,
toolCallId: "call_orphan" ,
toolName: "read" ,
content: [{ type: "text" , text: "orphan" }],
isError: false ,
},
{
role: "assistant" ,
content: [{ type: "text" , text: "ok" }],
},
]);
const out = sanitizeToolUseResultPairing(input);
expect(out.some((m) => m.role === "toolResult" )).toBe(false );
expect(out.map((m) => m.role)).toEqual(["user" , "assistant" ]);
});
it("skips tool call extraction for assistant messages with stopReason 'error'" , () => {
// When an assistant message has stopReason: "error", its tool_use blocks may be
// incomplete/malformed. We should NOT create synthetic tool_results for them,
// as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_error" , name: "exec" , arguments: {} }],
stopReason: "error" ,
},
{ role: "user" , content: "something went wrong" },
]);
const result = repairToolUseResultPairing(input);
// Should NOT add synthetic tool results for errored messages
expect(result.added).toHaveLength(0 );
// The assistant message should be passed through unchanged
expect(result.messages[0 ]?.role).toBe("assistant" );
expect(result.messages[1 ]?.role).toBe("user" );
expect(result.messages).toHaveLength(2 );
});
it("skips tool call extraction for assistant messages with stopReason 'aborted'" , () => {
// When a request is aborted mid-stream, the assistant message may have incomplete
// tool_use blocks (with partialJson). We should NOT create synthetic tool_results.
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_aborted" , name: "Bash" , arguments: {} }],
stopReason: "aborted" ,
},
{ role: "user" , content: "retrying after abort" },
]);
const result = repairToolUseResultPairing(input);
// Should NOT add synthetic tool results for aborted messages
expect(result.added).toHaveLength(0 );
// Messages should be passed through without synthetic insertions
expect(result.messages).toHaveLength(2 );
expect(result.messages[0 ]?.role).toBe("assistant" );
expect(result.messages[1 ]?.role).toBe("user" );
});
it("still repairs tool results for normal assistant messages with stopReason 'toolUse'" , () => {
// Normal tool calls (stopReason: "toolUse" or "stop") should still be repaired
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_normal" , name: "read" , arguments: {} }],
stopReason: "toolUse" ,
},
{ role: "user" , content: "user message" },
]);
const result = repairToolUseResultPairing(input);
// Should add a synthetic tool result for the missing result
expect(result.added).toHaveLength(1 );
expect(result.added[0 ]?.toolCallId).toBe("call_normal" );
});
function createAbortedAssistantTranscript() {
return castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_aborted" , name: "exec" , arguments: {} }],
stopReason: "aborted" ,
},
{
role: "toolResult" ,
toolCallId: "call_aborted" ,
toolName: "exec" ,
content: [{ type: "text" , text: "partial result" }],
isError: false ,
},
{ role: "user" , content: "retrying" },
]);
}
it("retains matching tool results that follow an aborted assistant message" , () => {
// Aborted assistant turns do not synthesize missing tool results, but real
// matching results in the same span remain part of the repaired transcript.
const input = createAbortedAssistantTranscript();
const result = repairToolUseResultPairing(input);
expect(result.droppedOrphanCount).toBe(0 );
expect(result.messages).toHaveLength(3 );
expect(result.messages[0 ]?.role).toBe("assistant" );
expect(result.messages[1 ]?.role).toBe("toolResult" );
expect(result.messages[2 ]?.role).toBe("user" );
expect(result.added).toHaveLength(0 );
});
it("drops matching tool results for aborted assistant messages when requested" , () => {
const input = createAbortedAssistantTranscript();
const result = repairToolUseResultPairing(input, {
erroredAssistantResultPolicy: "drop" ,
});
expect(result.droppedOrphanCount).toBe(0 );
expect(result.messages).toHaveLength(1 );
expect(result.messages[0 ]?.role).toBe("user" );
expect(result.added).toHaveLength(0 );
});
});
describe("sanitizeToolCallInputs" , () => {
function sanitizeAssistantContent(
content: unknown[],
options?: Parameters<typeof sanitizeToolCallInputs>[1 ],
) {
return sanitizeToolCallInputs(
castAgentMessages([
{
role: "assistant" ,
content,
},
]),
options,
);
}
function sanitizeAssistantToolCalls(
content: unknown[],
options?: Parameters<typeof sanitizeToolCallInputs>[1 ],
) {
return getAssistantToolCallBlocks(sanitizeAssistantContent(content, options));
}
it("drops tool calls missing input or arguments" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [{ type: "toolCall" , id: "call_1" , name: "read" }],
},
{ role: "user" , content: "hello" },
]);
const out = sanitizeToolCallInputs(input);
expect(out.map((m) => m.role)).toEqual(["user" ]);
});
it.each([
{
name: "drops tool calls with missing or blank name/id" ,
content: [
{ type: "toolCall" , id: "call_ok" , name: "read" , arguments: {} },
{ type: "toolCall" , id: "call_empty_name" , name: "" , arguments: {} },
{ type: "toolUse" , id: "call_blank_name" , name: " " , input: {} },
{ type: "functionCall" , id: "" , name: "exec" , arguments: {} },
],
options: undefined,
expectedIds: ["call_ok" ],
},
{
name: "drops tool calls with malformed or overlong names" ,
content: [
{ type: "toolCall" , id: "call_ok" , name: "read" , arguments: {} },
{
type: "toolCall" ,
id: "call_bad_chars" ,
name: 'toolu_01abc <|tool_call_argument_begin|> {"command"' ,
arguments: {},
},
{
type: "toolUse" ,
id: "call_too_long" ,
name: `read_${"x" .repeat(80 )}`,
input: {},
},
],
options: undefined,
expectedIds: ["call_ok" ],
},
{
name: "accepts punctuation-safe tool names during transcript repair" ,
content: [
{ type: "toolCall" , id: "call_ns" , name: "vigil-harbor__memory_status" , arguments: {} },
{ type: "toolUse" , id: "call_dotted" , name: "my.server:some_tool" , input: {} },
],
options: undefined,
expectedIds: ["call_ns" , "call_dotted" ],
},
{
name: "drops unknown tool names when an allowlist is provided" ,
content: [
{ type: "toolCall" , id: "call_ok" , name: "read" , arguments: {} },
{ type: "toolCall" , id: "call_unknown" , name: "write" , arguments: {} },
],
options: { allowedToolNames: ["read" ] },
expectedIds: ["call_ok" ],
},
])("$name" , ({ content, options, expectedIds }) => {
const toolCalls = sanitizeAssistantToolCalls(content, options);
const ids = toolCalls
.map((toolCall) => (toolCall as { id?: unknown }).id)
.filter((id): id is string => typeof id === "string" );
expect(ids).toEqual(expectedIds);
});
it("keeps valid tool calls and preserves text blocks" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{ type: "text" , text: "before" },
{ type: "toolUse" , id: "call_ok" , name: "read" , input: { path: "a" } },
{ type: "toolCall" , id: "call_drop" , name: "read" },
],
},
]);
const out = sanitizeToolCallInputs(input);
const assistant = out[0 ] as Extract<AgentMessage, { role: "assistant" }>;
const types = Array.isArray(assistant.content)
? assistant.content.map((block) => (block as { type?: unknown }).type)
: [];
expect(types).toEqual(["text" , "toolUse" ]);
});
it("drops signed-thinking assistant turns when sibling tool calls are not replay-safe" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "Let me check the gateway config." ,
thinkingSignature: "sig_gateway" ,
},
{
type: "toolCall" ,
id: "call_gateway" ,
name: "gateway" ,
arguments: {
action: "config.get" ,
path: "channels.telegram" ,
},
},
],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read" ],
allowProviderOwnedThinkingReplay: true ,
});
expect(out).toEqual([]);
});
it("drops signed-thinking assistant turns when sibling tool calls reuse an id" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "Let me reuse the tool id." ,
thinkingSignature: "sig_duplicate" ,
},
{ type: "toolCall" , id: "call_shared" , name: "read" , arguments: { path: "a" } },
{ type: "toolUse" , id: "call_shared" , name: "read" , input: { path: "b" } },
],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read" ],
allowProviderOwnedThinkingReplay: true ,
});
expect(out).toEqual([]);
});
it("drops later signed-thinking assistant turns that reuse an earlier signed tool id" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "First signed replay turn." ,
thinkingSignature: "sig_first" ,
},
{ type: "toolCall" , id: "call_shared" , name: "read" , arguments: { path: "a" } },
],
},
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "Second signed replay turn." ,
thinkingSignature: "sig_second" ,
},
{ type: "toolUse" , id: "call_shared" , name: "read" , input: { path: "b" } },
],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read" ],
allowProviderOwnedThinkingReplay: true ,
});
expect(out).toEqual([input[0 ]]);
});
it("drops signed-thinking assistant turns that would require attachment redaction" , () => {
const secret = "SIGNED_THINKING_ATTACHMENT_SECRET" ; // pragma: allowlist secret
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "Let me spawn a helper." ,
thinkingSignature: "sig_spawn" ,
},
{
type: "toolUse" ,
id: "call_spawn" ,
name: "sessions_spawn" ,
input: {
task: "inspect attachment" ,
attachments: [{ name: "snapshot.txt" , content: secret }],
},
},
],
},
]);
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["sessions_spawn" ],
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([
{
role: "assistant" ,
content: [
{
type: "thinking" ,
thinking: "Let me normalize this tool name." ,
thinkingSignature: "sig_generic" ,
},
{
type: "toolCall" ,
id: "call_read" ,
name: " read " ,
arguments: { path: "README.md" },
},
],
},
]);
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read" ] });
const assistant = out[0 ] as Extract<AgentMessage, { role: "assistant" }>;
expect(assistant.content).toEqual([
{
type: "thinking" ,
thinking: "Let me normalize this tool name." ,
thinkingSignature: "sig_generic" ,
},
{
type: "toolCall" ,
id: "call_read" ,
name: "read" ,
arguments: { path: "README.md" },
},
]);
});
it.each([
{
name: "trims leading whitespace from tool names" ,
content: [{ type: "toolCall" , id: "call_1" , name: " read" , arguments: {} }],
options: undefined,
expectedNames: ["read" ],
},
{
name: "trims trailing whitespace from tool names" ,
content: [{ type: "toolUse" , id: "call_1" , name: "exec " , input: { command: "ls" } }],
options: undefined,
expectedNames: ["exec" ],
},
{
name: "trims both leading and trailing whitespace from tool names" ,
content: [
{ type: "toolCall" , id: "call_1" , name: " read " , arguments: {} },
{ type: "toolUse" , id: "call_2" , name: " exec " , input: {} },
],
options: undefined,
expectedNames: ["read" , "exec" ],
},
{
name: "trims tool names and matches against allowlist" ,
content: [
{ type: "toolCall" , id: "call_1" , name: " read " , arguments: {} },
{ type: "toolCall" , id: "call_2" , name: " write " , arguments: {} },
],
options: { allowedToolNames: ["read" ] },
expectedNames: ["read" ],
},
])("$name" , ({ content, options, expectedNames }) => {
const toolCalls = sanitizeAssistantToolCalls(content, options);
const names = toolCalls
.map((toolCall) => (toolCall as { name?: unknown }).name)
.filter((name): name is string => typeof name === "string" );
expect(names).toEqual(expectedNames);
});
it("preserves toolUse input shape for sessions_spawn when no attachments are present" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "toolUse" ,
id: "call_1" ,
name: "sessions_spawn" ,
input: { task: "hello" },
},
],
},
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
expect(toolCalls).toHaveLength(1 );
expect(Object.hasOwn(toolCalls[0 ] ?? {}, "input" )).toBe(true );
expect(Object.hasOwn(toolCalls[0 ] ?? {}, "arguments" )).toBe(false );
expect((toolCalls[0 ] ?? {}).input).toEqual({ task: "hello" });
});
it("redacts sessions_spawn attachments for mixed-case and padded tool names" , () => {
const input = castAgentMessages([
{
role: "assistant" ,
content: [
{
type: "toolUse" ,
id: "call_1" ,
name: " SESSIONS_SPAWN " ,
input: {
task: "hello" ,
attachments: [{ name: "a.txt" , content: "SECRET" }],
},
},
],
},
]);
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out) as Array<Record<string, unknown>>;
expect(toolCalls).toHaveLength(1 );
expect((toolCalls[0 ] ?? {}).name).toBe("SESSIONS_SPAWN" );
const inputObj = (toolCalls[0 ]?.input ?? {}) as Record<string, unknown>;
const attachments = (inputObj.attachments ?? []) as Array<Record<string, unknown>>;
expect(attachments[0 ]?.content).toBe("__OPENCLAW_REDACTED__" );
});
it("preserves other block properties when trimming tool names" , () => {
const toolCalls = sanitizeAssistantToolCalls([
{ type: "toolCall" , id: "call_1" , name: " read " , arguments: { path: "/tmp/test" } },
]);
expect(toolCalls).toHaveLength(1 );
expect((toolCalls[0 ] as { name?: unknown }).name).toBe("read" );
expect((toolCalls[0 ] as { id?: unknown }).id).toBe("call_1" );
expect((toolCalls[0 ] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" });
});
});
describe("stripToolResultDetails" , () => {
it("removes details only from toolResult messages" , () => {
const input = castAgentMessages([
{
role: "toolResult" ,
toolCallId: "call_1" ,
toolName: "read" ,
content: [{ type: "text" , text: "ok" }],
details: { internal: true },
},
{ role: "assistant" , content: [{ type: "text" , text: "keep me" }], details: { no: "touch" } },
{ role: "user" , content: "hello" },
]);
const out = stripToolResultDetails(input) as unknown as Array<Record<string, unknown>>;
expect(Object.hasOwn(out[0 ] ?? {}, "details" )).toBe(false );
expect((out[0 ] ?? {}).role).toBe("toolResult" );
// Non-toolResult messages are preserved as-is.
expect(Object.hasOwn(out[1 ] ?? {}, "details" )).toBe(true );
expect((out[1 ] ?? {}).role).toBe("assistant" );
expect((out[2 ] ?? {}).role).toBe("user" );
});
it("returns the same array reference when there are no toolResult details" , () => {
const input = castAgentMessages([
{ role: "assistant" , content: [{ type: "text" , text: "a" }] },
{
role: "toolResult" ,
toolCallId: "call_1" ,
toolName: "read" ,
content: [{ type: "text" , text: "ok" }],
},
{ role: "user" , content: "b" },
]);
const out = stripToolResultDetails(input);
expect(out).toBe(input);
});
});
Messung V0.5 in Prozent C=99 H=99 G=98
¤ Dauer der Verarbeitung: 0.15 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland