Skip to content

Commit b0618d0

Browse files
committed
fix(stream): show thinking text
1 parent 849ba3b commit b0618d0

3 files changed

Lines changed: 139 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface NestedAgentGroup {
2222

2323
export type AgentGroupItem =
2424
| { type: 'text'; content: string }
25+
| { type: 'thinking'; content: string }
2526
| { type: 'tool'; data: ToolCallData }
2627
| { type: 'agent_group'; group: NestedAgentGroup }
2728

@@ -150,6 +151,16 @@ export function AgentGroup({
150151
</div>
151152
)
152153
}
154+
if (item.type === 'thinking') {
155+
return (
156+
<span
157+
key={`thinking-${idx}`}
158+
className='pl-6 text-[13px] text-[var(--text-secondary)] italic leading-[18px] opacity-50'
159+
>
160+
{item.content.trim()}
161+
</span>
162+
)
163+
}
153164
return (
154165
<span
155166
key={`text-${idx}`}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,69 @@ describe('parseBlocks span-identity tree', () => {
231231
expect(nested.group.agentName).toBe('file')
232232
})
233233

234+
it('renders subagent thinking as a muted thinking item and keeps the delegating spinner', () => {
235+
const blocks: ContentBlock[] = [
236+
subagentStart('workflow', 'S1', 'main'),
237+
{
238+
type: 'subagent_thinking',
239+
content: 'reasoning about the fix',
240+
spanId: 'S1',
241+
subagent: 'workflow',
242+
timestamp: 2,
243+
},
244+
]
245+
246+
const segments = parseBlocks(blocks)
247+
expect(segments).toHaveLength(1)
248+
if (segments[0].type !== 'agent_group') throw new Error('expected workflow group')
249+
// Thinking renders in the lane…
250+
expect(segments[0].items).toEqual([{ type: 'thinking', content: 'reasoning about the fix' }])
251+
// …but does not clear the delegating spinner (no real output yet).
252+
expect(segments[0].isDelegating).toBe(true)
253+
})
254+
255+
it('creates the lane on demand when thinking arrives before its subagent start', () => {
256+
const blocks: ContentBlock[] = [
257+
{
258+
type: 'subagent_thinking',
259+
content: 'early reasoning',
260+
spanId: 'S1',
261+
parentSpanId: 'main',
262+
subagent: 'workflow',
263+
timestamp: 1,
264+
},
265+
subagentStart('workflow', 'S1', 'main'),
266+
]
267+
268+
const segments = parseBlocks(blocks)
269+
const group = segments.find((s) => s.type === 'agent_group')
270+
if (!group || group.type !== 'agent_group') throw new Error('expected workflow group')
271+
expect(group.agentName).toBe('workflow')
272+
expect(group.items).toEqual([{ type: 'thinking', content: 'early reasoning' }])
273+
})
274+
275+
it('orders thinking before the lane text that follows it', () => {
276+
const blocks: ContentBlock[] = [
277+
subagentStart('workflow', 'S1', 'main'),
278+
{
279+
type: 'subagent_thinking',
280+
content: 'planning',
281+
spanId: 'S1',
282+
subagent: 'workflow',
283+
timestamp: 2,
284+
},
285+
{ type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'workflow', timestamp: 3 },
286+
]
287+
288+
const segments = parseBlocks(blocks)
289+
if (segments[0].type !== 'agent_group') throw new Error('expected workflow group')
290+
expect(segments[0].items).toEqual([
291+
{ type: 'thinking', content: 'planning' },
292+
{ type: 'text', content: 'done' },
293+
])
294+
expect(segments[0].isDelegating).toBe(false)
295+
})
296+
234297
it('falls back to legacy flat grouping when blocks have no span identity', () => {
235298
const blocks: ContentBlock[] = [
236299
{ type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 },
@@ -309,4 +372,25 @@ describe('parseBlocks legacy — thinking between top-level tools', () => {
309372
// The untagged chunk after thinking must NOT merge into the flushed lane.
310373
expect(groups[0].items).toHaveLength(1)
311374
})
375+
376+
it('renders subagent thinking inside the legacy lane', () => {
377+
const blocks: ContentBlock[] = [
378+
{ type: 'subagent', content: 'workflow', parentToolCallId: 'd1', timestamp: 1 },
379+
{
380+
type: 'subagent_thinking',
381+
content: 'legacy reasoning',
382+
parentToolCallId: 'd1',
383+
timestamp: 2,
384+
},
385+
{ type: 'subagent_text', content: 'output', parentToolCallId: 'd1', timestamp: 3 },
386+
]
387+
const segments = parseBlocks(blocks)
388+
const groups = segments.filter((s) => s.type === 'agent_group')
389+
expect(groups).toHaveLength(1)
390+
if (groups[0].type !== 'agent_group') throw new Error('expected group')
391+
expect(groups[0].items).toEqual([
392+
{ type: 'thinking', content: 'legacy reasoning' },
393+
{ type: 'text', content: 'output' },
394+
])
395+
})
312396
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
138138
}
139139
}
140140

141+
function appendThinkingItem(group: AgentGroupSegment, content: string): void {
142+
const lastItem = group.items[group.items.length - 1]
143+
if (lastItem?.type === 'thinking') {
144+
lastItem.content += content
145+
} else {
146+
group.items.push({ type: 'thinking', content })
147+
}
148+
}
149+
141150
/**
142151
* Deterministic span-identity grouping. Every subagent-scoped block carries the
143152
* stable `spanId` of the run that produced it and a `parentSpanId` linking it to
@@ -244,12 +253,26 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
244253
for (let i = 0; i < blocks.length; i++) {
245254
const block = blocks[i]
246255

247-
// Thinking is never rendered — a display-only omission; the reasoning is
248-
// still reduced and persisted upstream. This covers subagent lanes too:
249-
// reasoning-summary models (OpenAI effort-style) stream a summary on every
250-
// tool round, which would otherwise fill the agent card with reasoning
251-
// prose. The lane keeps its delegating spinner until real output arrives.
252-
if (block.type === 'thinking' || block.type === 'subagent_thinking') continue
256+
// Main-agent thinking is not rendered — a display-only omission; the
257+
// reasoning is still reduced upstream (and stripped at persistence).
258+
if (block.type === 'thinking') continue
259+
260+
// Subagent thinking renders in the lane as muted reasoning prose so a long
261+
// thinking round (extended thinking after a tool result can run for
262+
// minutes) reads as visible progress instead of a hung spinner. It does
263+
// NOT clear isDelegating: the header spinner keeps signaling activity
264+
// until real output or a tool arrives.
265+
if (block.type === 'subagent_thinking') {
266+
if (!block.content?.trim() || !block.spanId) continue
267+
let g = groupsBySpanId.get(block.spanId)
268+
// Out-of-order safety: see subagent_text branch below.
269+
if (!g && block.subagent) {
270+
g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId)
271+
}
272+
if (!g) continue
273+
appendThinkingItem(g, block.content)
274+
continue
275+
}
253276

254277
if (block.type === 'subagent_text') {
255278
if (!block.content || !block.spanId) continue
@@ -303,7 +326,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
303326
// emits its first content or tool (or ends). The legacy path derived this
304327
// from the dispatch tool_call, which the span path absorbs, so we set it
305328
// here. It is cleared in the subagent_text, scoped text, tool_call, and
306-
// subagent_end branches (thinking is skipped, so it keeps the spinner).
329+
// subagent_end branches (thinking renders but keeps the spinner).
307330
g.isDelegating = true
308331
g.isOpen = true
309332
continue
@@ -482,10 +505,20 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
482505
for (let i = 0; i < blocks.length; i++) {
483506
const block = blocks[i]
484507

485-
// Subagent thinking is never rendered (same display-only omission as the
486-
// span-tree parser): reasoning-summary models stream a summary every tool
487-
// round and would flood the lane with reasoning prose.
488-
if (block.type === 'subagent_thinking') continue
508+
// Subagent thinking renders in the lane as muted reasoning prose (same
509+
// rationale as the span-tree parser); it keeps the delegating spinner.
510+
if (block.type === 'subagent_thinking') {
511+
if (!block.content?.trim()) continue
512+
const g = findGroupForSubagentChunk(block.parentToolCallId)
513+
if (!g) continue
514+
const lastItem = g.items[g.items.length - 1]
515+
if (lastItem?.type === 'thinking') {
516+
lastItem.content += block.content
517+
} else {
518+
g.items.push({ type: 'thinking', content: block.content })
519+
}
520+
continue
521+
}
489522

490523
if (block.type === 'subagent_text') {
491524
if (!block.content) continue

0 commit comments

Comments
 (0)