diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 0a9c81b2a..e025762f8 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -187,6 +187,8 @@ interface OpenBlock { argsBufBytes?: number; webSearchArgsEmitted?: boolean; callId?: string; + /** Last reasoning part identity (item + summary/content index) seen by this thinking block. */ + reasoningPartKey?: string; } /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */ @@ -353,6 +355,21 @@ export function responsesSseToAnthropicSse( case "response.reasoning_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; ensureBlock("thinking"); + // The JSON path joins reasoning summary/content parts with "\n\n" + // (responsesJsonToAnthropicMessage); mirror that at part and item boundaries + // 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}`; + if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) { + emit("content_block_delta", { + type: "content_block_delta", index: open!.index, + delta: { type: "thinking_delta", thinking: "\n\n" }, + }); + } + open!.reasoningPartKey = partKey; emit("content_block_delta", { type: "content_block_delta", index: open!.index, delta: { type: "thinking_delta", thinking: data.delta }, diff --git a/tests/claude-outbound.test.ts b/tests/claude-outbound.test.ts index ab997823b..55b6da23e 100644 --- a/tests/claude-outbound.test.ts +++ b/tests/claude-outbound.test.ts @@ -207,6 +207,69 @@ describe("claude outbound SSE", () => { expect(startIndexes).toEqual([0, 1, 2]); }); + test("multi-part reasoning summaries keep the JSON path's part separator", async () => { + const upstream = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + sse("response.output_item.added", { output_index: 0, item: { type: "reasoning", id: "rs_1" } }), + sse("response.reasoning_summary_part.added", { item_id: "rs_1", output_index: 0, summary_index: 0, part: { type: "summary_text", text: "" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_1", output_index: 0, summary_index: 0, delta: "**A**\n\nOne." }), + sse("response.reasoning_summary_part.added", { item_id: "rs_1", output_index: 0, summary_index: 1, part: { type: "summary_text", text: "" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_1", output_index: 0, summary_index: 1, delta: "**B**\n\nTwo." }), + sse("response.output_item.done", { output_index: 0, item: { type: "reasoning", id: "rs_1" } }), + sse("response.output_item.added", { output_index: 1, item: { type: "reasoning", id: "rs_2" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_2", output_index: 1, summary_index: 0, delta: "Three." }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const msg = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFrom(upstream), "m"), "m") as Record; + // Parts within an item are separated; a new reasoning item opens its own block. + const thinkingBlocks = msg.content.filter((b: Record) => b.type === "thinking"); + expect(thinkingBlocks.map((b: Record) => b.thinking)).toEqual([ + "**A**\n\nOne.\n\n**B**\n\nTwo.", + "Three.", + ]); + + // Parity: the non-streaming translator joins the same summary parts identically. + const json = responsesJsonToAnthropicMessage({ + id: "resp_1", + status: "completed", + output: [{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "**A**\n\nOne." }, { type: "summary_text", text: "**B**\n\nTwo." }] }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, "m") as Record; + const jsonThinking = json.content.find((b: Record) => b.type === "thinking"); + expect(jsonThinking.thinking).toBe("**A**\n\nOne.\n\n**B**\n\nTwo."); + }); + + test("same-part deltas and index-free reasoning frames never get a separator", async () => { + const samePart = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_1", output_index: 0, summary_index: 0, delta: "Hel" }), + sse("response.reasoning_summary_text.delta", { item_id: "rs_1", output_index: 0, summary_index: 0, delta: "lo" }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const msg1 = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFrom(samePart), "m"), "m") as Record; + expect(msg1.content.find((b: Record) => b.type === "thinking").thinking).toBe("Hello"); + + const indexFree = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + sse("response.reasoning_text.delta", { delta: "A" }), + sse("response.reasoning_text.delta", { delta: "B" }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const msg2 = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFrom(indexFree), "m"), "m") as Record; + expect(msg2.content.find((b: Record) => b.type === "thinking").thinking).toBe("AB"); + }); + + test("indexed reasoning content parts get the separator too", async () => { + const indexedContent = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + sse("response.reasoning_text.delta", { item_id: "rs_1", output_index: 0, content_index: 0, delta: "A" }), + sse("response.reasoning_text.delta", { item_id: "rs_1", output_index: 0, content_index: 1, delta: "B" }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const msg = await collectAnthropicMessage(responsesSseToAnthropicSse(streamFrom(indexedContent), "m"), "m") as Record; + expect(msg.content.find((b: Record) => b.type === "thinking").thinking).toBe("A\n\nB"); + }); + 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" } }),