From 348f5ded15e09bd3333b3ae7a434cb129e0aca80 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 7 Aug 2026 16:31:38 +0900 Subject: [PATCH] fix(claude): bound streamed reasoning part identity --- src/claude/outbound.ts | 14 ++++++++++---- tests/claude-outbound.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index e025762f85..bddeed7263 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -23,6 +23,10 @@ function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); } +function boundedReasoningIndex(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : -1; +} + function uuid(): string { return crypto.randomUUID().replace(/-/g, ""); } @@ -187,7 +191,7 @@ interface OpenBlock { argsBufBytes?: number; webSearchArgsEmitted?: boolean; callId?: string; - /** Last reasoning part identity (item + summary/content index) seen by this thinking block. */ + /** Last bounded reasoning part identity seen by this thinking block. */ reasoningPartKey?: string; } @@ -360,9 +364,11 @@ export function responsesSseToAnthropicSse( // so multi-part summaries do not glue into one run-on paragraph. Frames // without part indices produce a constant key and never get a separator. const slot = eventName === "response.reasoning_summary_text.delta" - ? `s${String(data.summary_index)}` - : `c${String(data.content_index)}`; - const partKey = `${String(data.item_id)}:${slot}`; + ? `s${boundedReasoningIndex(data.summary_index)}` + : `c${boundedReasoningIndex(data.content_index)}`; + // Use only validated numeric indexes so retained state has a fixed upper bound; + // item_id and malformed indexes are untrusted upstream strings and may be huge. + const partKey = `${boundedReasoningIndex(data.output_index)}:${slot}`; if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) { emit("content_block_delta", { type: "content_block_delta", index: open!.index, diff --git a/tests/claude-outbound.test.ts b/tests/claude-outbound.test.ts index 8651f779d1..78a2d93f35 100644 --- a/tests/claude-outbound.test.ts +++ b/tests/claude-outbound.test.ts @@ -259,6 +259,32 @@ describe("claude outbound SSE", () => { expect(msg2.content.find((b: Record) => b.type === "thinking").thinking).toBe("AB"); }); + test("reasoning part identity does not retain untrusted string metadata", async () => { + const untrusted = "x".repeat(1024 * 1024); + const upstream = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + sse("response.reasoning_summary_text.delta", { + item_id: untrusted, + output_index: untrusted, + summary_index: untrusted, + delta: "A", + }), + sse("response.reasoning_summary_text.delta", { + item_id: `${untrusted}2`, + output_index: `${untrusted}2`, + summary_index: `${untrusted}2`, + delta: "B", + }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + + const msg = await collectAnthropicMessage( + responsesSseToAnthropicSse(streamFromChunks([upstream]), "m"), + "m", + ) as Record; + expect(msg.content.find((b: Record) => b.type === "thinking").thinking).toBe("AB"); + }); + test("data-only Responses frames infer event names from payload types", async () => { const upstream = [ dataOnlySse({ type: "response.created", response: { id: "resp_data_only", status: "in_progress" } }),