Skip to content

Commit fc08985

Browse files
committed
fix(agents): preserve signed thinking payloads
1 parent 6a324f6 commit fc08985

6 files changed

Lines changed: 234 additions & 20 deletions

File tree

extensions/amazon-bedrock/stream.runtime.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,21 @@ function bedrockModel(overrides: Record<string, unknown>) {
1818
}
1919

2020
function signedThinkingContext(modelId: string) {
21+
const highSurrogate = String.fromCharCode(0xd83d);
2122
return {
2223
messages: [
2324
{
2425
role: "assistant",
2526
api: "bedrock-converse-stream",
2627
provider: "amazon-bedrock",
2728
model: modelId,
28-
content: [{ type: "thinking", thinking: "private reasoning", thinkingSignature: "sig-1" }],
29+
content: [
30+
{
31+
type: "thinking",
32+
thinking: `private${highSurrogate}reasoning`,
33+
thinkingSignature: "sig-1",
34+
},
35+
],
2936
},
3037
],
3138
} as never;
@@ -47,7 +54,10 @@ describe("Bedrock reasoning replay", () => {
4754
expect(messages[0]?.content).toEqual([
4855
{
4956
reasoningContent: {
50-
reasoningText: { text: "private reasoning", signature: "sig-1" },
57+
reasoningText: {
58+
text: `private${String.fromCharCode(0xd83d)}reasoning`,
59+
signature: "sig-1",
60+
},
5161
},
5262
},
5363
]);
@@ -61,7 +71,7 @@ describe("Bedrock reasoning replay", () => {
6171
"none",
6272
);
6373

64-
expect(messages[0]?.content).toEqual([{ text: "private reasoning" }]);
74+
expect(messages[0]?.content).toEqual([{ text: "privatereasoning" }]);
6575
});
6676
});
6777

extensions/amazon-bedrock/stream.runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ function convertMessages(
662662
contentBlocks.push({
663663
reasoningContent: {
664664
reasoningText: {
665-
text: sanitizeSurrogates(c.thinking),
665+
text: c.thinking,
666666
signature: c.thinkingSignature,
667667
},
668668
},

src/agents/anthropic-transport-stream.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,54 @@ describe("anthropic transport stream", () => {
708708
expect(result.usage.output).toBe(9);
709709
});
710710

711+
it("preserves provider-signed Anthropic thinking text on ingest", async () => {
712+
const highSurrogate = String.fromCharCode(0xd83d);
713+
const signedThinking = `keep${highSurrogate}signed`;
714+
guardedFetchMock.mockResolvedValueOnce(
715+
createSseResponse([
716+
{
717+
type: "message_start",
718+
message: { id: "msg_1", usage: { input_tokens: 6, output_tokens: 0 } },
719+
},
720+
{
721+
type: "content_block_start",
722+
index: 0,
723+
content_block: { type: "thinking", thinking: signedThinking, signature: "sig_1" },
724+
},
725+
{
726+
type: "content_block_delta",
727+
index: 0,
728+
delta: { type: "signature_delta", signature: "sig_2" },
729+
},
730+
{
731+
type: "content_block_stop",
732+
index: 0,
733+
},
734+
{
735+
type: "message_delta",
736+
delta: { stop_reason: "end_turn" },
737+
usage: { input_tokens: 6, output_tokens: 9 },
738+
},
739+
]),
740+
);
741+
742+
const result = await runTransportStream(
743+
makeAnthropicTransportModel(),
744+
{
745+
messages: [{ role: "user", content: "think" }],
746+
} as AnthropicStreamContext,
747+
{
748+
apiKey: "sk-ant-api",
749+
} as AnthropicStreamOptions,
750+
);
751+
752+
expect(result.content[0]).toMatchObject({
753+
type: "thinking",
754+
thinking: signedThinking,
755+
thinkingSignature: "sig_2",
756+
});
757+
});
758+
711759
it("captures OpenAI-style reasoning_content deltas from Anthropic-compatible streams", async () => {
712760
guardedFetchMock.mockResolvedValueOnce(
713761
createSseResponse([
@@ -1090,6 +1138,7 @@ describe("anthropic transport stream", () => {
10901138
});
10911139

10921140
it("replays reasoning_content from compatible Anthropic thinking blocks", async () => {
1141+
const highSurrogate = String.fromCharCode(0xd83d);
10931142
await runTransportStream(
10941143
makeAnthropicTransportModel({
10951144
id: "mimo-v2.6-pro",
@@ -1110,7 +1159,7 @@ describe("anthropic transport stream", () => {
11101159
content: [
11111160
{
11121161
type: "thinking",
1113-
thinking: "Need to answer politely.",
1162+
thinking: `Need${highSurrogate} to answer politely.`,
11141163
thinkingSignature: "reasoning_content",
11151164
},
11161165
{ type: "text", text: "Hello!" },
@@ -1154,6 +1203,51 @@ describe("anthropic transport stream", () => {
11541203
]);
11551204
});
11561205

1206+
it("preserves provider-signed Anthropic thinking text on replay", async () => {
1207+
const highSurrogate = String.fromCharCode(0xd83d);
1208+
const signedThinking = `keep${highSurrogate}signed`;
1209+
await runTransportStream(
1210+
makeAnthropicTransportModel(),
1211+
{
1212+
messages: [
1213+
{ role: "user", content: "hello" },
1214+
{
1215+
role: "assistant",
1216+
provider: "anthropic",
1217+
api: "anthropic-messages",
1218+
model: "claude-sonnet-4-6",
1219+
stopReason: "stop",
1220+
timestamp: 0,
1221+
content: [
1222+
{
1223+
type: "thinking",
1224+
thinking: signedThinking,
1225+
thinkingSignature: "sig_1",
1226+
},
1227+
],
1228+
},
1229+
{ role: "user", content: "again" },
1230+
],
1231+
} as AnthropicStreamContext,
1232+
{
1233+
apiKey: "sk-ant-api",
1234+
reasoning: "high",
1235+
} as AnthropicStreamOptions,
1236+
);
1237+
1238+
const assistantMessage = findRecord(
1239+
latestAnthropicRequest().payload.messages,
1240+
(record) => record.role === "assistant",
1241+
);
1242+
expect(assistantMessage.content).toEqual([
1243+
{
1244+
type: "thinking",
1245+
thinking: signedThinking,
1246+
signature: "sig_1",
1247+
},
1248+
]);
1249+
});
1250+
11571251
it("backfills empty reasoning_content thinking blocks for compatible Anthropic tool-use replays", async () => {
11581252
await runTransportStream(
11591253
makeAnthropicTransportModel({

src/agents/anthropic-transport-stream.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,10 @@ function convertAnthropicMessages(
380380
text: sanitizeTransportPayloadText(block.thinking),
381381
});
382382
} else {
383-
const thinking = sanitizeTransportPayloadText(block.thinking);
383+
const thinking =
384+
block.thinkingSignature === "reasoning_content"
385+
? sanitizeTransportPayloadText(block.thinking)
386+
: block.thinking;
384387
if (block.thinkingSignature === "reasoning_content") {
385388
if (allowReasoningContentReplay) {
386389
blocks.push({
@@ -1121,9 +1124,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
11211124
}
11221125
if (contentBlock?.type === "thinking") {
11231126
const thinking =
1124-
typeof contentBlock.thinking === "string"
1125-
? sanitizeTransportPayloadText(contentBlock.thinking)
1126-
: "";
1127+
typeof contentBlock.thinking === "string" ? contentBlock.thinking : "";
11271128
const block: TransportContentBlock = {
11281129
type: "thinking",
11291130
thinking,

src/llm/providers/anthropic.test.ts

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,43 @@ vi.mock("@anthropic-ai/sdk", () => ({
2121

2222
import { streamAnthropic } from "./anthropic.js";
2323

24+
function createSseResponse(events: Record<string, unknown>[] = []): Response {
25+
const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
26+
return new Response(body, {
27+
status: 200,
28+
headers: { "content-type": "text/event-stream" },
29+
});
30+
}
31+
32+
function makeAnthropicModel(overrides: Partial<Model<"anthropic-messages">> = {}) {
33+
return {
34+
id: "claude-sonnet-4-6",
35+
name: "Claude Sonnet 4.6",
36+
provider: "anthropic",
37+
api: "anthropic-messages",
38+
baseUrl: "https://api.anthropic.com",
39+
reasoning: true,
40+
input: ["text"],
41+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
42+
contextWindow: 200_000,
43+
maxTokens: 4096,
44+
...overrides,
45+
} satisfies Model<"anthropic-messages">;
46+
}
47+
2448
describe("Anthropic provider", () => {
2549
beforeEach(() => {
2650
anthropicMockState.configs = [];
2751
});
2852

2953
it("keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", async () => {
30-
const model = {
31-
id: "claude-sonnet-4-6",
32-
name: "Claude Sonnet 4.6",
54+
const model = makeAnthropicModel({
3355
provider: "cloudflare-ai-gateway",
34-
api: "anthropic-messages",
3556
baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages",
36-
reasoning: true,
37-
input: ["text"],
38-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
39-
contextWindow: 200_000,
40-
maxTokens: 4096,
4157
headers: {
4258
"cf-aig-authorization": "Bearer gateway-token",
4359
},
44-
} satisfies Model<"anthropic-messages">;
60+
});
4561
const context = {
4662
messages: [{ role: "user", content: "hello", timestamp: 1 }],
4763
} satisfies Context;
@@ -62,4 +78,93 @@ describe("Anthropic provider", () => {
6278
expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined();
6379
expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token");
6480
});
81+
82+
it("preserves provider-signed Anthropic thinking text on replay", async () => {
83+
const highSurrogate = String.fromCharCode(0xd83d);
84+
const signedThinking = `keep${highSurrogate}signed`;
85+
let capturedPayload: unknown;
86+
const client = {
87+
messages: {
88+
create: vi.fn(() => ({
89+
asResponse: () =>
90+
Promise.resolve(
91+
createSseResponse([
92+
{
93+
type: "message_start",
94+
message: { id: "msg_1", usage: { input_tokens: 1, output_tokens: 0 } },
95+
},
96+
{
97+
type: "message_delta",
98+
delta: { stop_reason: "end_turn" },
99+
usage: { input_tokens: 1, output_tokens: 1 },
100+
},
101+
{ type: "message_stop" },
102+
]),
103+
),
104+
})),
105+
},
106+
};
107+
108+
const stream = streamAnthropic(
109+
makeAnthropicModel(),
110+
{
111+
messages: [
112+
{ role: "user", content: "hello", timestamp: 0 },
113+
{
114+
role: "assistant",
115+
provider: "anthropic",
116+
api: "anthropic-messages",
117+
model: "claude-sonnet-4-6",
118+
stopReason: "stop",
119+
timestamp: 0,
120+
usage: {
121+
input: 0,
122+
output: 0,
123+
cacheRead: 0,
124+
cacheWrite: 0,
125+
totalTokens: 0,
126+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
127+
},
128+
content: [
129+
{
130+
type: "thinking",
131+
thinking: signedThinking,
132+
thinkingSignature: "sig_1",
133+
},
134+
{
135+
type: "thinking",
136+
thinking: `sanitize${highSurrogate}synthetic`,
137+
thinkingSignature: "reasoning_content",
138+
},
139+
],
140+
},
141+
{ role: "user", content: "again", timestamp: 0 },
142+
],
143+
},
144+
{
145+
apiKey: "sk-ant-provider",
146+
client: client as never,
147+
onPayload: (payload) => {
148+
capturedPayload = payload;
149+
},
150+
},
151+
);
152+
153+
await stream.result();
154+
155+
const payload = capturedPayload as { messages: Array<{ role: string; content: unknown[] }> };
156+
const assistantMessage = payload.messages.find((message) => message.role === "assistant");
157+
expect(assistantMessage?.content).toEqual([
158+
{
159+
type: "thinking",
160+
thinking: signedThinking,
161+
signature: "sig_1",
162+
},
163+
{
164+
type: "thinking",
165+
thinking: "sanitizesynthetic",
166+
signature: "reasoning_content",
167+
},
168+
]);
169+
});
65170
});

src/llm/providers/anthropic.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1119,9 +1119,13 @@ function convertMessages(
11191119
text: sanitizeSurrogates(block.thinking),
11201120
});
11211121
} else {
1122+
const thinking =
1123+
block.thinkingSignature === "reasoning_content"
1124+
? sanitizeSurrogates(block.thinking)
1125+
: block.thinking;
11221126
blocks.push({
11231127
type: "thinking",
1124-
thinking: sanitizeSurrogates(block.thinking),
1128+
thinking,
11251129
signature: block.thinkingSignature,
11261130
});
11271131
}

0 commit comments

Comments
 (0)