Skip to content

Commit 5f836da

Browse files
committed
progress on streaming refactor
1 parent b6c3018 commit 5f836da

30 files changed

Lines changed: 2361 additions & 962 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { ToolCallData, ToolCallStatus } from '../../../../types'
6+
import type { AgentGroupItem } from './agent-group'
7+
import { isAgentGroupResolved } from './agent-group'
8+
9+
let toolSeq = 0
10+
11+
function tool(status: ToolCallStatus): AgentGroupItem {
12+
toolSeq += 1
13+
const data: ToolCallData = {
14+
id: `tool-${toolSeq}`,
15+
toolName: 'grep',
16+
displayTitle: 'Searching',
17+
status,
18+
}
19+
return { type: 'tool', data }
20+
}
21+
22+
function text(content: string): AgentGroupItem {
23+
return { type: 'text', content }
24+
}
25+
26+
function group(items: AgentGroupItem[], isDelegating = false): AgentGroupItem {
27+
return {
28+
type: 'agent_group',
29+
group: {
30+
id: `group-${toolSeq}`,
31+
agentName: 'deploy',
32+
agentLabel: 'Deploy',
33+
items,
34+
isDelegating,
35+
isOpen: true,
36+
},
37+
}
38+
}
39+
40+
describe('isAgentGroupResolved', () => {
41+
it('is unresolved when there is no work yet', () => {
42+
expect(isAgentGroupResolved([])).toBe(false)
43+
expect(isAgentGroupResolved([text('thinking...')])).toBe(false)
44+
})
45+
46+
it('resolves once every own tool is terminal', () => {
47+
expect(isAgentGroupResolved([tool('success')])).toBe(true)
48+
expect(isAgentGroupResolved([tool('success'), tool('error')])).toBe(true)
49+
})
50+
51+
it('stays unresolved while any own tool is still executing', () => {
52+
expect(isAgentGroupResolved([tool('success'), tool('executing')])).toBe(false)
53+
})
54+
55+
it('resolves a parent whose only work is a finished child group', () => {
56+
expect(isAgentGroupResolved([group([tool('success')])])).toBe(true)
57+
})
58+
59+
it('stays unresolved while a nested child is still delegating', () => {
60+
expect(isAgentGroupResolved([group([], true)])).toBe(false)
61+
})
62+
63+
it('stays unresolved while a nested child has an executing tool', () => {
64+
expect(isAgentGroupResolved([group([tool('executing')])])).toBe(false)
65+
})
66+
67+
it('resolves deep nesting only when every descendant is terminal', () => {
68+
expect(isAgentGroupResolved([group([group([tool('success')])])])).toBe(true)
69+
expect(isAgentGroupResolved([group([group([tool('executing')])])])).toBe(false)
70+
})
71+
})

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

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
44
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallData } from '../../../../types'
7-
import { getAgentIcon } from '../../utils'
7+
import { getAgentIcon, isToolDone } from '../../utils'
88
import { ToolCallItem } from './tool-call-item'
99

1010
/**
@@ -35,15 +35,18 @@ interface AgentGroupProps {
3535
defaultExpanded?: boolean
3636
}
3737

38-
function isToolDone(status: ToolCallData['status']): boolean {
39-
return (
40-
status === 'success' ||
41-
status === 'error' ||
42-
status === 'cancelled' ||
43-
status === 'skipped' ||
44-
status === 'rejected' ||
45-
status === 'interrupted'
46-
)
38+
export function isAgentGroupResolved(items: AgentGroupItem[]): boolean {
39+
let hasWork = false
40+
for (const item of items) {
41+
if (item.type === 'tool') {
42+
hasWork = true
43+
if (!isToolDone(item.data.status)) return false
44+
} else if (item.type === 'agent_group') {
45+
hasWork = true
46+
if (item.group.isDelegating || !isAgentGroupResolved(item.group.items)) return false
47+
}
48+
}
49+
return hasWork
4750
}
4851

4952
export function AgentGroup({
@@ -56,20 +59,18 @@ export function AgentGroup({
5659
}: AgentGroupProps) {
5760
const AgentIcon = getAgentIcon(agentName)
5861
const hasItems = items.length > 0
59-
const toolItems = items.filter(
60-
(item): item is Extract<AgentGroupItem, { type: 'tool' }> => item.type === 'tool'
61-
)
62-
const allDone = toolItems.length > 0 && toolItems.every((t) => isToolDone(t.data.status))
63-
// Only a live turn can be delegating. Once the turn is terminal (complete,
64-
// errored, or stopped) no subagent should spin — even one aborted before its
65-
// first tool call, where `allDone` is false because there are no tools yet.
66-
const showDelegatingSpinner = isStreaming && isDelegating && !allDone
62+
const resolved = isAgentGroupResolved(items)
63+
// Pure projection of the run's own state: a subagent header spins while it is
64+
// delegating with no resolved work yet. A terminal turn closes the lane (its
65+
// subagent block is stamped ended), which clears `isDelegating`, so no
66+
// transport gating is needed to stop an aborted-before-first-tool spinner.
67+
const showDelegatingSpinner = isDelegating && !resolved
6768

6869
// Expand only while the turn is live and the group is still open or working.
6970
// Once the turn ends (isStreaming false) — or a subagent closes mid-turn — the
7071
// group auto-collapses, so finished subagent blocks never stay expanded. A
7172
// manual toggle pins the choice for the rest of the message.
72-
const autoExpanded = isStreaming && (defaultExpanded || !allDone)
73+
const autoExpanded = isStreaming && (defaultExpanded || !resolved)
7374
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
7475
const expanded = manualExpanded ?? autoExpanded
7576

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
2-
export { AgentGroup } from './agent-group'
2+
export { AgentGroup, isAgentGroupResolved } from './agent-group'
33
export { CircleStop } from './tool-call-item'

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
22
import { PillsRing } from '@/components/emcn'
33
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
44
import type { ToolCallStatus } from '../../../../types'
5-
import { getToolIcon } from '../../utils'
5+
import { getToolIcon, resolveToolDisplayState } from '../../utils'
66

77
function CircleCheck({ className }: { className?: string }) {
88
return (
@@ -58,13 +58,14 @@ function Hyphen({ className }: { className?: string }) {
5858
}
5959

6060
function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
61-
if (status === 'executing') {
61+
const display = resolveToolDisplayState(status)
62+
if (display === 'spinner') {
6263
return <PillsRing className='size-[15px] text-[var(--text-tertiary)]' animate />
6364
}
64-
if (status === 'cancelled') {
65+
if (display === 'cancelled') {
6566
return <CircleStop className='size-[15px] text-[var(--text-tertiary)]' />
6667
}
67-
if (status === 'interrupted') {
68+
if (display === 'interrupted') {
6869
return <Hyphen className='size-[15px] text-[var(--text-tertiary)]' />
6970
}
7071
const Icon = getToolIcon(toolName)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,21 +279,30 @@ interface ChatContentProps {
279279
isStreaming?: boolean
280280
onOptionSelect?: (id: string) => void
281281
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
282+
onRevealStateChange?: (isRevealing: boolean) => void
282283
}
283284

284285
function ChatContentInner({
285286
content,
286287
isStreaming = false,
287288
onOptionSelect,
288289
onWorkspaceResourceSelect,
290+
onRevealStateChange,
289291
}: ChatContentProps) {
290292
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
291293
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
292294

295+
const onRevealStateChangeRef = useRef(onRevealStateChange)
296+
onRevealStateChangeRef.current = onRevealStateChange
297+
293298
const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content])
294299
const streamedContent = useSmoothText(displayContent, isStreaming)
295300
const isRevealing = isStreaming || streamedContent.length < displayContent.length
296301

302+
useEffect(() => {
303+
onRevealStateChangeRef.current?.(isRevealing)
304+
}, [isRevealing])
305+
297306
/**
298307
* One-way latch: once a message has streamed in this mount, keep rendering it
299308
* through Streamdown's streaming/animation pipeline for the rest of its life.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
2-
export { AgentGroup, CircleStop } from './agent-group'
2+
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
33
export { ChatContent } from './chat-content'
44
export { Options } from './options'
55
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export {
22
assistantMessageHasRenderableContent,
33
MessageContent,
44
} from './message-content'
5+
export type { MessagePhase } from './utils'

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,23 @@ describe('parseBlocks span-identity tree', () => {
5656
expect(nested.group.items.some((item) => item.type === 'tool')).toBe(true)
5757
})
5858

59+
it('clears the parent delegating flag once it has spawned a child, leaving only the child active', () => {
60+
const blocks: ContentBlock[] = [
61+
subagentStart('workflow', 'S1', 'main'),
62+
subagentStart('deploy', 'S2', 'S1'),
63+
]
64+
65+
const segments = parseBlocks(blocks)
66+
expect(segments).toHaveLength(1)
67+
const workflow = segments[0]
68+
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
69+
expect(workflow.isDelegating).toBe(false)
70+
71+
const nested = workflow.items.find((item) => item.type === 'agent_group')
72+
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested deploy group')
73+
expect(nested.group.isDelegating).toBe(true)
74+
})
75+
5976
it('keeps two top-level subagents as siblings', () => {
6077
const blocks: ContentBlock[] = [
6178
subagentStart('workflow', 'S1', 'main'),

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

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { memo, useMemo } from 'react'
3+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { stripVersionSuffix } from '@sim/utils/string'
55
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
66
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
@@ -18,6 +18,7 @@ import {
1818
PendingTagIndicator,
1919
ThinkingBlock,
2020
} from './components'
21+
import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
2122

2223
const FILE_SUBAGENT_ID = 'file'
2324

@@ -93,17 +94,6 @@ function resolveAgentLabel(key: string): string {
9394
return SUBAGENT_LABELS[key] ?? formatToolName(key)
9495
}
9596

96-
function isToolDone(status: ToolCallData['status']): boolean {
97-
return (
98-
status === 'success' ||
99-
status === 'error' ||
100-
status === 'cancelled' ||
101-
status === 'skipped' ||
102-
status === 'rejected' ||
103-
status === 'interrupted'
104-
)
105-
}
106-
10797
function isDelegatingTool(tc: NonNullable<ContentBlock['toolCall']>): boolean {
10898
return tc.status === 'executing'
10999
}
@@ -226,6 +216,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
226216
if (parentSpanId && parentSpanId !== SPAN_ROOT) {
227217
const parent = groupsBySpanId.get(parentSpanId)
228218
if (parent) {
219+
parent.isDelegating = false
229220
parent.items.push({ type: 'agent_group', group })
230221
return
231222
}
@@ -688,24 +679,42 @@ interface MessageContentProps {
688679
fallbackContent: string
689680
isStreaming: boolean
690681
onOptionSelect?: (id: string) => void
682+
onPhaseChange?: (phase: MessagePhase) => void
691683
}
692684

693685
function MessageContentInner({
694686
blocks,
695687
fallbackContent,
696688
isStreaming = false,
697689
onOptionSelect,
690+
onPhaseChange,
698691
}: MessageContentProps) {
699692
const { onWorkspaceResourceSelect } = useChatSurface()
700693
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
701694

695+
const [trailingRevealing, setTrailingRevealing] = useState(false)
696+
const handleTrailingRevealChange = useCallback((revealing: boolean) => {
697+
setTrailingRevealing(revealing)
698+
}, [])
699+
702700
const segments: MessageSegment[] =
703701
parsed.length > 0
704702
? parsed
705703
: fallbackContent?.trim()
706704
? [{ type: 'text' as const, content: fallbackContent }]
707705
: []
708706

707+
const lastSegment = segments[segments.length - 1]
708+
const hasTrailingTextSegment = lastSegment?.type === 'text'
709+
const isRevealing = hasTrailingTextSegment && trailingRevealing
710+
const phase = deriveMessagePhase({ isStreaming, isRevealing })
711+
712+
const onPhaseChangeRef = useRef(onPhaseChange)
713+
onPhaseChangeRef.current = onPhaseChange
714+
useEffect(() => {
715+
onPhaseChangeRef.current?.(phase)
716+
}, [phase])
717+
709718
if (segments.length === 0) {
710719
if (isStreaming) {
711720
return (
@@ -717,21 +726,16 @@ function MessageContentInner({
717726
return null
718727
}
719728

720-
const lastSegment = segments[segments.length - 1]
721729
const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped'
722730

723-
let allLastGroupToolsDone = false
724-
if (lastSegment.type === 'agent_group') {
725-
const toolItems = lastSegment.items.filter((item) => item.type === 'tool')
726-
allLastGroupToolsDone =
727-
toolItems.length > 0 && toolItems.every((t) => t.type === 'tool' && isToolDone(t.data.status))
728-
}
729-
730-
const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end')
731-
const showTrailingThinking =
732-
isStreaming &&
733-
!hasTrailingContent &&
734-
(lastSegment.type === 'thinking' || hasSubagentEnded || allLastGroupToolsDone)
731+
// Deterministic "between steps" signal: the turn is still streaming, nothing
732+
// is actively running (a running tool/subagent renders its own spinner), and
733+
// no trailing text is being revealed. Derived from explicit node state rather
734+
// than guessing from the shape of the last segment.
735+
const hasRunningWork = blocks.some(
736+
(b) => b.toolCall?.status === 'executing' || (b.type === 'subagent' && b.endedAt === undefined)
737+
)
738+
const showTrailingThinking = phase === 'streaming' && !hasTrailingContent && !hasRunningWork
735739

736740
return (
737741
<div className='space-y-[10px]'>
@@ -749,6 +753,9 @@ function MessageContentInner({
749753
})}
750754
onOptionSelect={onOptionSelect}
751755
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
756+
onRevealStateChange={
757+
i === segments.length - 1 ? handleTrailingRevealChange : undefined
758+
}
752759
/>
753760
)
754761
case 'thinking': {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { deriveMessagePhase, resolveToolDisplayState } from './utils'
6+
7+
describe('deriveMessagePhase', () => {
8+
it('is streaming whenever the transport is live', () => {
9+
expect(deriveMessagePhase({ isStreaming: true, isRevealing: false })).toBe('streaming')
10+
expect(deriveMessagePhase({ isStreaming: true, isRevealing: true })).toBe('streaming')
11+
})
12+
13+
it('is revealing when the transport stopped but text is still draining', () => {
14+
expect(deriveMessagePhase({ isStreaming: false, isRevealing: true })).toBe('revealing')
15+
})
16+
17+
it('is settled once neither the transport nor the reveal is active', () => {
18+
expect(deriveMessagePhase({ isStreaming: false, isRevealing: false })).toBe('settled')
19+
})
20+
})
21+
22+
describe('resolveToolDisplayState', () => {
23+
it('spins iff the tool is executing — a pure projection of its own status', () => {
24+
expect(resolveToolDisplayState('executing')).toBe('spinner')
25+
})
26+
27+
it('maps cancelled and interrupted to their own glyphs', () => {
28+
expect(resolveToolDisplayState('cancelled')).toBe('cancelled')
29+
expect(resolveToolDisplayState('interrupted')).toBe('interrupted')
30+
})
31+
32+
it('renders terminal successes and errors as the tool icon', () => {
33+
expect(resolveToolDisplayState('success')).toBe('icon')
34+
expect(resolveToolDisplayState('error')).toBe('icon')
35+
expect(resolveToolDisplayState('skipped')).toBe('icon')
36+
expect(resolveToolDisplayState('rejected')).toBe('icon')
37+
})
38+
})

0 commit comments

Comments
 (0)