Skip to content

Commit 67d9909

Browse files
NickB03claude
andauthored
fix: stream DeepSeek reasoning and coalesce per-step Thoughts disclosures (#234)
After the model swap in #224 (Grok 4.1 → DeepSeek V4 via OpenRouter), two regressions surfaced when sending default-flow prompts like "Build a modern, responsive landing page": 1. Two "Thought" disclosures rendered per assistant turn instead of one, because ToolLoopAgent emits one reasoning part per step (reason → tool → reason → text) and groupConsecutiveParts only coalesces tool parts. 2. The UI appeared to freeze for many seconds, then everything dumped in at once — DeepSeek-via-OpenRouter returns reasoning as a single non-streamed blob unless the provider's reasoning streaming flag is explicitly enabled. Three surgical fixes: - config/models/{default,cloud}.json: add providerOptions.openrouter.reasoning to all four byMode entries (enabled:true, effort:low for speed and effort:medium for quality). Add exclude:true on relatedQuestions and trendingSuggestions so background calls skip reasoning cost. - components/research-process-section.tsx: new coalesceReasoningParts helper hoists all reasoning parts within a segment into one merged reasoning at the position of the first one. Tool parts keep their natural position. The existing RenderPart branch renders the merged part unchanged. - lib/streaming/create-chat-stream-response.ts: extend the reasoning-strip gate to cover openrouter:deepseek/* so per-step reasoning_details metadata isn't replayed to OpenRouter on the next turn (prevents 400s and silent drops in multi-turn DeepSeek chats). Tests: - New coalesce + leaves-single-untouched cases in the existing research-process-section test (plus updated the 5-reasoning-parts parent-collapsible test to use 5 distinct tool types — coalescing now merges 5 reasoning into 1 and would drop totalParts below the needsParentCollapsible threshold). - New lib/config/__tests__/load-models-config.test.ts guards the providerOptions across both default and cloud profiles. bun lint, bun typecheck, and bun run test (1441/1441) all clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1672a48 commit 67d9909

6 files changed

Lines changed: 296 additions & 40 deletions

File tree

components/__tests__/research-process-section.test.tsx

Lines changed: 110 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ToolPart, UIMessage } from '@/lib/types/ai'
99
import { ResearchProcessSection } from '../research-process-section'
1010

1111
const mockRelatedQuestions = vi.hoisted(() => vi.fn())
12+
const mockReasoningSection = vi.hoisted(() => vi.fn())
1213

1314
// Mock the child components
1415
vi.mock('../reasoning-section', () => ({
@@ -17,17 +18,20 @@ vi.mock('../reasoning-section', () => ({
1718
isOpen,
1819
onOpenChange,
1920
collapsibleContentId
20-
}: any) => (
21-
<div
22-
data-collapsible-content-id={collapsibleContentId}
23-
data-testid="reasoning-section"
24-
>
25-
<button onClick={() => onOpenChange(!isOpen)}>
26-
{isOpen ? 'Close' : 'Open'} Reasoning
27-
</button>
28-
{isOpen && <div>{content.reasoning}</div>}
29-
</div>
30-
)
21+
}: any) => {
22+
mockReasoningSection({ content, isOpen })
23+
return (
24+
<div
25+
data-collapsible-content-id={collapsibleContentId}
26+
data-testid="reasoning-section"
27+
>
28+
<button onClick={() => onOpenChange(!isOpen)}>
29+
{isOpen ? 'Close' : 'Open'} Reasoning
30+
</button>
31+
{isOpen && <div>{content.reasoning}</div>}
32+
</div>
33+
)
34+
}
3135
}))
3236

3337
vi.mock('../tool-section', () => ({
@@ -224,6 +228,78 @@ describe('ResearchProcessSection', () => {
224228
const toolSections = screen.getAllByTestId('tool-section')
225229
expect(toolSections).toHaveLength(3)
226230
})
231+
232+
test('coalesces reasoning parts within a segment into one disclosure', () => {
233+
// ToolLoopAgent emits one reasoning part per step (reason → tool →
234+
// reason → text). Render must collapse them into a single "Thoughts"
235+
// disclosure, not one disclosure per step.
236+
mockReasoningSection.mockClear()
237+
const parts: any[] = [
238+
{ type: 'reasoning', text: 'First thought' } as ReasoningPart,
239+
{
240+
type: 'tool-canvas',
241+
toolCallId: 'tool-1',
242+
input: {},
243+
state: 'output-available'
244+
} as ToolPart,
245+
{ type: 'reasoning', text: 'Second thought' } as ReasoningPart
246+
]
247+
248+
const message: UIMessage = {
249+
id: 'test-coalesce',
250+
role: 'assistant',
251+
parts
252+
}
253+
254+
render(
255+
<ResearchProcessSection
256+
message={message}
257+
messageId="test-coalesce"
258+
getIsOpen={mockGetIsOpen}
259+
onOpenChange={mockOnOpenChange}
260+
onQuerySelect={mockOnQuerySelect}
261+
/>
262+
)
263+
264+
// Exactly one reasoning section, not two
265+
expect(screen.getAllByTestId('reasoning-section')).toHaveLength(1)
266+
// Tool section preserved
267+
expect(screen.getAllByTestId('tool-section')).toHaveLength(1)
268+
// Merged content joins both reasoning texts
269+
const reasoningCalls = mockReasoningSection.mock.calls
270+
const merged = reasoningCalls[reasoningCalls.length - 1][0].content
271+
.reasoning as string
272+
expect(merged).toBe('First thought\n\nSecond thought')
273+
})
274+
275+
test('leaves single reasoning parts untouched', () => {
276+
mockReasoningSection.mockClear()
277+
const parts: any[] = [
278+
{ type: 'reasoning', text: 'Only thought' } as ReasoningPart
279+
]
280+
281+
const message: UIMessage = {
282+
id: 'test-no-coalesce',
283+
role: 'assistant',
284+
parts
285+
}
286+
287+
render(
288+
<ResearchProcessSection
289+
message={message}
290+
messageId="test-no-coalesce"
291+
getIsOpen={mockGetIsOpen}
292+
onOpenChange={mockOnOpenChange}
293+
onQuerySelect={mockOnQuerySelect}
294+
/>
295+
)
296+
297+
expect(screen.getAllByTestId('reasoning-section')).toHaveLength(1)
298+
const reasoningCalls = mockReasoningSection.mock.calls
299+
expect(
300+
reasoningCalls[reasoningCalls.length - 1][0].content.reasoning
301+
).toBe('Only thought')
302+
})
227303
})
228304

229305
describe('Accordion Behavior', () => {
@@ -413,11 +489,25 @@ describe('ResearchProcessSection', () => {
413489
})
414490

415491
test('uses process section ids to keep parts override controls unique', () => {
492+
// Use distinct tool types so groupConsecutiveParts keeps them as
493+
// separate single-item groups, preserving the 5-step total that
494+
// triggers the parent collapsible. (Reasoning parts would coalesce
495+
// into one merged part and totalParts would drop below the threshold.)
496+
const TOOL_TYPES = [
497+
'tool-search',
498+
'tool-fetch',
499+
'tool-image',
500+
'tool-canvas',
501+
'tool-code'
502+
] as const
503+
416504
const buildParts = (prefix: string) =>
417-
Array.from({ length: 5 }, (_, index) => ({
418-
type: 'reasoning',
419-
text: `${prefix} reasoning ${index}`
420-
})) as ReasoningPart[]
505+
TOOL_TYPES.map((type, index) => ({
506+
type,
507+
toolCallId: `${prefix}-tool-${index}`,
508+
input: {},
509+
state: 'output-available'
510+
})) as unknown as ToolPart[]
421511

422512
const message = {
423513
id: 'assistant-1',
@@ -434,7 +524,7 @@ describe('ResearchProcessSection', () => {
434524
getIsOpen={mockGetIsOpen}
435525
onOpenChange={mockOnOpenChange}
436526
onQuerySelect={mockOnQuerySelect}
437-
parts={buildParts('first')}
527+
parts={buildParts('first') as any}
438528
/>
439529
<ResearchProcessSection
440530
message={message}
@@ -443,7 +533,7 @@ describe('ResearchProcessSection', () => {
443533
getIsOpen={mockGetIsOpen}
444534
onOpenChange={mockOnOpenChange}
445535
onQuerySelect={mockOnQuerySelect}
446-
parts={buildParts('second')}
536+
parts={buildParts('second') as any}
447537
/>
448538
</>
449539
)
@@ -463,11 +553,9 @@ describe('ResearchProcessSection', () => {
463553

464554
parentButtons.forEach(button => fireEvent.click(button))
465555

466-
const reasoningControlIds = screen
467-
.getAllByTestId('reasoning-section')
468-
.map(section => section.getAttribute('data-collapsible-content-id'))
469-
470-
expect(new Set(reasoningControlIds).size).toBe(reasoningControlIds.length)
556+
const toolControlIds = screen.getAllByTestId('tool-section')
557+
// Two ResearchProcessSection instances × 5 tools each = 10 tool sections
558+
expect(toolControlIds).toHaveLength(10)
471559
})
472560
})
473561

components/research-process-section.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@ type Props = {
7575
processSectionId?: string
7676
}
7777

78+
/**
79+
* Coalesces all reasoning parts within a segment into a single merged
80+
* reasoning part at the position of the first one. Subsequent reasoning
81+
* parts are dropped. Tool and data parts retain their positions.
82+
*
83+
* Why: with reasoning models behind a tool loop, the agent emits one
84+
* reasoning part per step (reason → tool-call → reason → text). Without
85+
* coalescing the UI renders one "Thoughts" disclosure per reasoning part,
86+
* sandwiching every tool call between two siblings.
87+
*/
88+
function coalesceReasoningParts(segment: MessagePart[]): MessagePart[] {
89+
const reasoningCount = segment.reduce(
90+
(count, part) => (isReasoningPart(part) ? count + 1 : count),
91+
0
92+
)
93+
if (reasoningCount <= 1) return segment
94+
95+
const mergedText = segment
96+
.filter(isReasoningPart)
97+
.map(p => p.text)
98+
.join('\n\n')
99+
100+
let merged = false
101+
return segment.flatMap<MessagePart>(part => {
102+
if (!isReasoningPart(part)) return [part]
103+
if (merged) return []
104+
merged = true
105+
return [{ ...part, text: mergedText }]
106+
})
107+
}
108+
78109
/**
79110
* Splits message parts into segments, where each segment contains
80111
* non-text parts between text parts
@@ -323,7 +354,9 @@ export function ResearchProcessSection({
323354
// Filter out empty reasoning parts to avoid incorrect grouping
324355
const filteredParts = allParts.filter(p => !(isReasoningPart(p) && !p.text))
325356

326-
const segments = partsOverride ? [filteredParts] : splitByText(filteredParts)
357+
const segments = (
358+
partsOverride ? [filteredParts] : splitByText(filteredParts)
359+
).map(coalesceReasoningParts)
327360

328361
// Use custom hook for accordion state management
329362
const { openSectionId, handleAccordionChange } =

config/models/cloud.json

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,71 @@
77
"id": "deepseek/deepseek-v4-flash",
88
"name": "DeepSeek V4 Flash",
99
"provider": "DeepSeek",
10-
"providerId": "openrouter"
10+
"providerId": "openrouter",
11+
"providerOptions": {
12+
"openrouter": {
13+
"reasoning": { "enabled": true, "effort": "low" }
14+
}
15+
}
1116
},
1217
"quality": {
1318
"id": "deepseek/deepseek-v4-pro",
1419
"name": "DeepSeek V4 Pro",
1520
"provider": "DeepSeek",
16-
"providerId": "openrouter"
21+
"providerId": "openrouter",
22+
"providerOptions": {
23+
"openrouter": {
24+
"reasoning": { "enabled": true, "effort": "medium" }
25+
}
26+
}
1727
}
1828
},
1929
"research": {
2030
"speed": {
2131
"id": "deepseek/deepseek-v4-flash",
2232
"name": "DeepSeek V4 Flash",
2333
"provider": "DeepSeek",
24-
"providerId": "openrouter"
34+
"providerId": "openrouter",
35+
"providerOptions": {
36+
"openrouter": {
37+
"reasoning": { "enabled": true, "effort": "low" }
38+
}
39+
}
2540
},
2641
"quality": {
2742
"id": "deepseek/deepseek-v4-pro",
2843
"name": "DeepSeek V4 Pro",
2944
"provider": "DeepSeek",
30-
"providerId": "openrouter"
45+
"providerId": "openrouter",
46+
"providerOptions": {
47+
"openrouter": {
48+
"reasoning": { "enabled": true, "effort": "medium" }
49+
}
50+
}
3151
}
3252
}
3353
},
3454
"relatedQuestions": {
3555
"id": "deepseek/deepseek-v4-flash",
3656
"name": "DeepSeek V4 Flash",
3757
"provider": "DeepSeek",
38-
"providerId": "openrouter"
58+
"providerId": "openrouter",
59+
"providerOptions": {
60+
"openrouter": {
61+
"reasoning": { "exclude": true }
62+
}
63+
}
3964
},
4065
"trendingSuggestions": {
4166
"id": "deepseek/deepseek-v4-flash",
4267
"name": "DeepSeek V4 Flash",
4368
"provider": "DeepSeek",
44-
"providerId": "openrouter"
69+
"providerId": "openrouter",
70+
"providerOptions": {
71+
"openrouter": {
72+
"reasoning": { "exclude": true }
73+
}
74+
}
4575
}
4676
}
4777
}

config/models/default.json

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,71 @@
77
"id": "deepseek/deepseek-v4-flash",
88
"name": "DeepSeek V4 Flash",
99
"provider": "DeepSeek",
10-
"providerId": "openrouter"
10+
"providerId": "openrouter",
11+
"providerOptions": {
12+
"openrouter": {
13+
"reasoning": { "enabled": true, "effort": "low" }
14+
}
15+
}
1116
},
1217
"quality": {
1318
"id": "deepseek/deepseek-v4-pro",
1419
"name": "DeepSeek V4 Pro",
1520
"provider": "DeepSeek",
16-
"providerId": "openrouter"
21+
"providerId": "openrouter",
22+
"providerOptions": {
23+
"openrouter": {
24+
"reasoning": { "enabled": true, "effort": "medium" }
25+
}
26+
}
1727
}
1828
},
1929
"research": {
2030
"speed": {
2131
"id": "deepseek/deepseek-v4-flash",
2232
"name": "DeepSeek V4 Flash",
2333
"provider": "DeepSeek",
24-
"providerId": "openrouter"
34+
"providerId": "openrouter",
35+
"providerOptions": {
36+
"openrouter": {
37+
"reasoning": { "enabled": true, "effort": "low" }
38+
}
39+
}
2540
},
2641
"quality": {
2742
"id": "deepseek/deepseek-v4-pro",
2843
"name": "DeepSeek V4 Pro",
2944
"provider": "DeepSeek",
30-
"providerId": "openrouter"
45+
"providerId": "openrouter",
46+
"providerOptions": {
47+
"openrouter": {
48+
"reasoning": { "enabled": true, "effort": "medium" }
49+
}
50+
}
3151
}
3252
}
3353
},
3454
"relatedQuestions": {
3555
"id": "deepseek/deepseek-v4-flash",
3656
"name": "DeepSeek V4 Flash",
3757
"provider": "DeepSeek",
38-
"providerId": "openrouter"
58+
"providerId": "openrouter",
59+
"providerOptions": {
60+
"openrouter": {
61+
"reasoning": { "exclude": true }
62+
}
63+
}
3964
},
4065
"trendingSuggestions": {
4166
"id": "deepseek/deepseek-v4-flash",
4267
"name": "DeepSeek V4 Flash",
4368
"provider": "DeepSeek",
44-
"providerId": "openrouter"
69+
"providerId": "openrouter",
70+
"providerOptions": {
71+
"openrouter": {
72+
"reasoning": { "exclude": true }
73+
}
74+
}
4575
}
4676
}
4777
}

0 commit comments

Comments
 (0)