Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/claude/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions tests/claude-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,32 @@ describe("claude outbound SSE", () => {
expect(msg2.content.find((b: Record<string, unknown>) => 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<string, any>;
expect(msg.content.find((b: Record<string, unknown>) => 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" } }),
Expand Down
Loading