mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): preserve signed thinking payloads (#87493)
This commit is contained in:
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -662,7 +662,7 @@ function convertMessages(
|
||||
contentBlocks.push({
|
||||
reasoningContent: {
|
||||
reasoningText: {
|
||||
text: sanitizeSurrogates(c.thinking),
|
||||
text: c.thinking,
|
||||
signature: c.thinkingSignature,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user