fix: gate immutable thinking replay repair to anthropic

This commit is contained in:
Shakker
2026-04-12 04:11:42 +01:00
committed by Shakker
parent a383e09f52
commit 98e89f5939
3 changed files with 54 additions and 4 deletions

View File

@@ -418,6 +418,7 @@ export async function sanitizeSessionHistory(params: {
: sanitizedImages;
const sanitizedToolCalls = sanitizeToolCallInputs(droppedThinking, {
allowedToolNames: params.allowedToolNames,
preserveImmutableThinkingTurns: policy.validateAnthropicTurns,
});
const repairedTools = policy.repairToolUseResultPairing
? sanitizeToolUseResultPairing(sanitizedToolCalls, {

View File

@@ -403,7 +403,10 @@ describe("sanitizeToolCallInputs", () => {
},
]);
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["read"],
preserveImmutableThinkingTurns: true,
});
expect(out).toEqual([]);
});
@@ -432,12 +435,52 @@ describe("sanitizeToolCallInputs", () => {
},
]);
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["sessions_spawn"] });
const out = sanitizeToolCallInputs(input, {
allowedToolNames: ["sessions_spawn"],
preserveImmutableThinkingTurns: true,
});
expect(out).toEqual([]);
expect(JSON.stringify(out)).not.toContain(secret);
});
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",

View File

@@ -176,7 +176,7 @@ function isReplaySafeThinkingAssistantTurn(
return false;
}
}
return sawToolCall || content.some((block) => isThinkingLikeBlock(block));
return sawToolCall;
}
function makeMissingToolResult(params: {
@@ -232,6 +232,7 @@ export type ToolCallInputRepairReport = {
export type ToolCallInputRepairOptions = {
allowedToolNames?: Iterable<string>;
preserveImmutableThinkingTurns?: boolean;
};
export type ErroredAssistantResultPolicy = "preserve" | "drop";
@@ -269,6 +270,7 @@ export function repairToolCallInputs(
let changed = false;
const out: AgentMessage[] = [];
const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames);
const preserveImmutableThinkingTurns = options?.preserveImmutableThinkingTurns === true;
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
@@ -281,7 +283,11 @@ export function repairToolCallInputs(
continue;
}
if (msg.content.some((block) => isThinkingLikeBlock(block))) {
if (
preserveImmutableThinkingTurns &&
msg.content.some((block) => isThinkingLikeBlock(block)) &&
countRawToolCallBlocks(msg.content) > 0
) {
// Signed Anthropic thinking blocks must remain byte-for-byte stable on
// replay. Preserve the turn only if every sibling tool call is already
// valid and requires no redaction or normalization. Otherwise drop the