fix(ai): preserved unwrapped thinking signatures

Tracked whether Anthropic thinking-envelope normalization actually stripped a wrapper before mutating thinking bytes or clearing signatures.

Added coverage for signed thinking with surrounding whitespace to preserve byte-for-byte replay.

Fixes #2695
This commit is contained in:
roboomp
2026-06-15 19:36:56 +00:00
committed by can1357
parent e24f789de4
commit 7936ea2ac3
2 changed files with 39 additions and 1 deletions
+3 -1
View File
@@ -1467,10 +1467,12 @@ const THINKING_ENVELOPE_CLOSE = "</thinking>";
function unwrapAnthropicThinkingEnvelope(text: string): string | undefined {
let current = text.trim();
let stripped = false;
while (current.startsWith(THINKING_ENVELOPE_OPEN) && current.endsWith(THINKING_ENVELOPE_CLOSE)) {
current = current.slice(THINKING_ENVELOPE_OPEN.length, current.length - THINKING_ENVELOPE_CLOSE.length).trim();
stripped = true;
}
return current === text ? undefined : current;
return stripped ? current : undefined;
}
function createEmptyUsage(premiumRequests?: number): Usage {
@@ -349,6 +349,42 @@ describe("anthropic stream envelope handling", () => {
{ type: "text", text: "Check logs before accepting container health." },
]);
});
it("preserves signed thinking bytes when no literal thinking envelope is present", async () => {
const signedThinking = "\nCheck logs before accepting container health.\n";
vi.spyOn(AnthropicMessages.prototype, "create").mockImplementation(
() => createMockRequest(createThinkingSuccessEvents(signedThinking)) as never,
);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
for await (const _ of stream) {
// drain stream
}
const result = await stream.result();
expect(result.stopReason).toBe("stop");
expect(result.content).toHaveLength(1);
const block = result.content[0];
expect(block?.type).toBe("thinking");
if (block?.type !== "thinking") {
throw new Error("Expected signed thinking content");
}
expect(block.thinking).toBe(signedThinking);
expect(block.thinkingSignature).toBe("sig_thinking");
const replayParams = convertAnthropicMessages(
[
{ role: "user", content: "Say hi", timestamp: 1 },
result,
{ role: "user", content: "follow up", timestamp: 2 },
],
model,
false,
);
const replayAssistant = replayParams.find(param => param.role === "assistant");
expect(replayAssistant?.content).toEqual([
{ type: "thinking", thinking: signedThinking, signature: "sig_thinking" },
]);
});
it("drops replayed closed blocks after a duplicate message_start instead of duplicating content", async () => {
const events: MockAnthropicEvent[] = [