fix(agents): preserve signed thinking payloads (#87493)

This commit is contained in:
Josh Avant
2026-05-27 21:57:41 -07:00
committed by GitHub
parent d10d30c5fa
commit 4a45a259ec
6 changed files with 234 additions and 20 deletions

View File

@@ -18,6 +18,7 @@ function bedrockModel(overrides: Record<string, unknown>) {
}
function signedThinkingContext(modelId: string) {
const highSurrogate = String.fromCharCode(0xd83d);
return {
messages: [
{
@@ -25,7 +26,13 @@ function signedThinkingContext(modelId: string) {
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
model: modelId,
content: [{ type: "thinking", thinking: "private reasoning", thinkingSignature: "sig-1" }],
content: [
{
type: "thinking",
thinking: `private${highSurrogate}reasoning`,
thinkingSignature: "sig-1",
},
],
},
],
} as never;
@@ -47,7 +54,10 @@ describe("Bedrock reasoning replay", () => {
expect(messages[0]?.content).toEqual([
{
reasoningContent: {
reasoningText: { text: "private reasoning", signature: "sig-1" },
reasoningText: {
text: `private${String.fromCharCode(0xd83d)}reasoning`,
signature: "sig-1",
},
},
},
]);
@@ -61,7 +71,7 @@ describe("Bedrock reasoning replay", () => {
"none",
);
expect(messages[0]?.content).toEqual([{ text: "private reasoning" }]);
expect(messages[0]?.content).toEqual([{ text: "privatereasoning" }]);
});
});

View File

@@ -662,7 +662,7 @@ function convertMessages(
contentBlocks.push({
reasoningContent: {
reasoningText: {
text: sanitizeSurrogates(c.thinking),
text: c.thinking,
signature: c.thinkingSignature,
},
},

View File

@@ -708,6 +708,54 @@ describe("anthropic transport stream", () => {
expect(result.usage.output).toBe(9);
});
it("preserves provider-signed Anthropic thinking text on ingest", async () => {
const highSurrogate = String.fromCharCode(0xd83d);
const signedThinking = `keep${highSurrogate}signed`;
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_1", usage: { input_tokens: 6, output_tokens: 0 } },
},
{
type: "content_block_start",
index: 0,
content_block: { type: "thinking", thinking: signedThinking, signature: "sig_1" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "sig_2" },
},
{
type: "content_block_stop",
index: 0,
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 6, output_tokens: 9 },
},
]),
);
const result = await runTransportStream(
makeAnthropicTransportModel(),
{
messages: [{ role: "user", content: "think" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
} as AnthropicStreamOptions,
);
expect(result.content[0]).toMatchObject({
type: "thinking",
thinking: signedThinking,
thinkingSignature: "sig_2",
});
});
it("captures OpenAI-style reasoning_content deltas from Anthropic-compatible streams", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
@@ -1090,6 +1138,7 @@ describe("anthropic transport stream", () => {
});
it("replays reasoning_content from compatible Anthropic thinking blocks", async () => {
const highSurrogate = String.fromCharCode(0xd83d);
await runTransportStream(
makeAnthropicTransportModel({
id: "mimo-v2.6-pro",
@@ -1110,7 +1159,7 @@ describe("anthropic transport stream", () => {
content: [
{
type: "thinking",
thinking: "Need to answer politely.",
thinking: `Need${highSurrogate} to answer politely.`,
thinkingSignature: "reasoning_content",
},
{ type: "text", text: "Hello!" },
@@ -1154,6 +1203,51 @@ describe("anthropic transport stream", () => {
]);
});
it("preserves provider-signed Anthropic thinking text on replay", async () => {
const highSurrogate = String.fromCharCode(0xd83d);
const signedThinking = `keep${highSurrogate}signed`;
await runTransportStream(
makeAnthropicTransportModel(),
{
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-6",
stopReason: "stop",
timestamp: 0,
content: [
{
type: "thinking",
thinking: signedThinking,
thinkingSignature: "sig_1",
},
],
},
{ role: "user", content: "again" },
],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
reasoning: "high",
} as AnthropicStreamOptions,
);
const assistantMessage = findRecord(
latestAnthropicRequest().payload.messages,
(record) => record.role === "assistant",
);
expect(assistantMessage.content).toEqual([
{
type: "thinking",
thinking: signedThinking,
signature: "sig_1",
},
]);
});
it("backfills empty reasoning_content thinking blocks for compatible Anthropic tool-use replays", async () => {
await runTransportStream(
makeAnthropicTransportModel({

View File

@@ -380,7 +380,10 @@ function convertAnthropicMessages(
text: sanitizeTransportPayloadText(block.thinking),
});
} else {
const thinking = sanitizeTransportPayloadText(block.thinking);
const thinking =
block.thinkingSignature === "reasoning_content"
? sanitizeTransportPayloadText(block.thinking)
: block.thinking;
if (block.thinkingSignature === "reasoning_content") {
if (allowReasoningContentReplay) {
blocks.push({
@@ -1121,9 +1124,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
}
if (contentBlock?.type === "thinking") {
const thinking =
typeof contentBlock.thinking === "string"
? sanitizeTransportPayloadText(contentBlock.thinking)
: "";
typeof contentBlock.thinking === "string" ? contentBlock.thinking : "";
const block: TransportContentBlock = {
type: "thinking",
thinking,

View File

@@ -21,27 +21,43 @@ vi.mock("@anthropic-ai/sdk", () => ({
import { streamAnthropic } from "./anthropic.js";
function createSseResponse(events: Record<string, unknown>[] = []): Response {
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function makeAnthropicModel(overrides: Partial<Model<"anthropic-messages">> = {}) {
return {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 4096,
...overrides,
} satisfies Model<"anthropic-messages">;
}
describe("Anthropic provider", () => {
beforeEach(() => {
anthropicMockState.configs = [];
});
it("keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", async () => {
const model = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
const model = makeAnthropicModel({
provider: "cloudflare-ai-gateway",
api: "anthropic-messages",
baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 4096,
headers: {
"cf-aig-authorization": "Bearer gateway-token",
},
} satisfies Model<"anthropic-messages">;
});
const context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
} satisfies Context;
@@ -62,4 +78,93 @@ describe("Anthropic provider", () => {
expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined();
expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token");
});
it("preserves provider-signed Anthropic thinking text on replay", async () => {
const highSurrogate = String.fromCharCode(0xd83d);
const signedThinking = `keep${highSurrogate}signed`;
let capturedPayload: unknown;
const client = {
messages: {
create: vi.fn(() => ({
asResponse: () =>
Promise.resolve(
createSseResponse([
{
type: "message_start",
message: { id: "msg_1", usage: { input_tokens: 1, output_tokens: 0 } },
},
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 1, output_tokens: 1 },
},
{ type: "message_stop" },
]),
),
})),
},
};
const stream = streamAnthropic(
makeAnthropicModel(),
{
messages: [
{ role: "user", content: "hello", timestamp: 0 },
{
role: "assistant",
provider: "anthropic",
api: "anthropic-messages",
model: "claude-sonnet-4-6",
stopReason: "stop",
timestamp: 0,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
content: [
{
type: "thinking",
thinking: signedThinking,
thinkingSignature: "sig_1",
},
{
type: "thinking",
thinking: `sanitize${highSurrogate}synthetic`,
thinkingSignature: "reasoning_content",
},
],
},
{ role: "user", content: "again", timestamp: 0 },
],
},
{
apiKey: "sk-ant-provider",
client: client as never,
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
await stream.result();
const payload = capturedPayload as { messages: Array<{ role: string; content: unknown[] }> };
const assistantMessage = payload.messages.find((message) => message.role === "assistant");
expect(assistantMessage?.content).toEqual([
{
type: "thinking",
thinking: signedThinking,
signature: "sig_1",
},
{
type: "thinking",
thinking: "sanitizesynthetic",
signature: "reasoning_content",
},
]);
});
});

View File

@@ -1119,9 +1119,13 @@ function convertMessages(
text: sanitizeSurrogates(block.thinking),
});
} else {
const thinking =
block.thinkingSignature === "reasoning_content"
? sanitizeSurrogates(block.thinking)
: block.thinking;
blocks.push({
type: "thinking",
thinking: sanitizeSurrogates(block.thinking),
thinking,
signature: block.thinkingSignature,
});
}