fix(claude): separate reasoning summary parts in streamed thinking - #944
fix(claude): separate reasoning summary parts in streamed thinking#944DevMello wants to merge 1 commit into
Conversation
The streaming translator folded every summary part into one thinking block with no separator, so multi-part summaries rendered as run-on text. The JSON path already joins parts with a blank line; the stream now emits the same separator at part boundaries.
📝 WalkthroughWalkthroughChangesReasoning stream handling
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/claude-outbound.test.ts`:
- Around line 242-260: Add coverage in the “same-part deltas and index-free
reasoning frames never get a separator” test for indexed reasoning content by
emitting deltas with content_index 0 and 1. Collect the converted message and
assert its thinking content contains the two parts separated by "\n\n",
exercising the content_index branch while preserving the existing same-part and
index-free assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 8b37aca0-694d-480d-a5f3-7c2226c9e0ff
📒 Files selected for processing (2)
src/claude/outbound.tstests/claude-outbound.test.ts
| 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<string, any>; | ||
| expect(msg1.content.find((b: Record<string, unknown>) => 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<string, any>; | ||
| expect(msg2.content.find((b: Record<string, unknown>) => b.type === "thinking").thinking).toBe("AB"); | ||
| }); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Cover indexed reasoning content parts.
The new content_index branch is not exercised. The current test only verifies repeated index-free frames. Add deltas with content_index: 0 and content_index: 1, then assert that the result contains "\n\n" between the parts.
Proposed regression case
+ const indexedContent = [
+ 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 indexedMsg = await collectAnthropicMessage(
+ responsesSseToAnthropicSse(streamFrom(indexedContent), "m"),
+ "m",
+ ) as Record<string, any>;
+ expect(indexedMsg.content.find((b: Record<string, unknown>) => b.type === "thinking").thinking)
+ .toBe("A\n\nB");📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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<string, any>; | |
| expect(msg1.content.find((b: Record<string, unknown>) => 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<string, any>; | |
| expect(msg2.content.find((b: Record<string, unknown>) => b.type === "thinking").thinking).toBe("AB"); | |
| }); | |
| 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<string, any>; | |
| expect(msg1.content.find((b: Record<string, unknown>) => 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<string, any>; | |
| expect(msg2.content.find((b: Record<string, unknown>) => b.type === "thinking").thinking).toBe("AB"); | |
| const indexedContent = [ | |
| 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 indexedMsg = await collectAnthropicMessage( | |
| responsesSseToAnthropicSse(streamFrom(indexedContent), "m"), | |
| "m", | |
| ) as Record<string, any>; | |
| expect(indexedMsg.content.find((b: Record<string, unknown>) => b.type === "thinking").thinking) | |
| .toBe("A\n\nB"); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/claude-outbound.test.ts` around lines 242 - 260, Add coverage in the
“same-part deltas and index-free reasoning frames never get a separator” test
for indexed reasoning content by emitting deltas with content_index 0 and 1.
Collect the converted message and assert its thinking content contains the two
parts separated by "\n\n", exercising the content_index branch while preserving
the existing same-part and index-free assertions.
|
Carried onto the review stack as #953 (stack 3/3), unmodified. Your commits were taken with Verified on the stack: This PR stays open until #953 lands. If a maintainer prefers to take yours directly instead, that path is unaffected — the stack commits get dropped and this one merges. Once #953 merges I'll close this as carried, with the credit already in the commit history rather than in a comment. Stack: #951 (plan, base Thanks for the fix. |
Summary
On the native ChatGPT backend the upstream Responses stream is translated straight into Anthropic SSE. Reasoning summaries arrive there in parts, each its own paragraph with a bold headline. The streaming translator folded every part into one thinking block with no separator, so thinking text rendered as run-on lines like "wants X.Planning the fix". The non-streaming translator in the same file already joins parts with a blank line. The stream now tracks part identity on the delta frames and emits the same blank-line separator at part boundaries. Frames without part indices keep a constant key and behave as before, and a new reasoning item still opens its own block.
Verification
tests/claude-outbound.test.ts: multi-part summaries match the JSON path's joined text, and same-part or index-free deltas never get a separator.bun run test,typecheck,lint:gui,privacy:scan.Checklist
Summary by CodeRabbit
Bug Fixes
Tests