Add subagents

This commit is contained in:
Siddharth Ganesan
2026-01-05 18:32:35 -08:00
committed by Emir Karabeg
parent fd76e98f0e
commit df80309c3b
6 changed files with 824 additions and 132 deletions

View File

@@ -4,74 +4,21 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
/**
* Max height for thinking content before internal scrolling kicks in
*/
const THINKING_MAX_HEIGHT = 125
/**
* Interval for auto-scroll during streaming (ms)
*/
const SCROLL_INTERVAL = 100
/**
* Timer update interval in milliseconds
*/
const TIMER_UPDATE_INTERVAL = 100
/**
* Milliseconds threshold for displaying as seconds
*/
const SECONDS_THRESHOLD = 1000
/**
* Props for the ShimmerOverlayText component
*/
interface ShimmerOverlayTextProps {
/** Label text to display */
label: string
/** Value text to display */
value: string
/** Whether the shimmer animation is active */
active?: boolean
}
/**
* ShimmerOverlayText component for thinking block
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
*
* @param props - Component props
* @returns Text with optional shimmer overlay effect
*/
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
return (
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
<span className='text-[var(--text-muted)]'>{value}</span>
{active ? (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
{value}
</span>
</span>
) : null}
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
</span>
)
}
/**
* Props for the ThinkingBlock component
*/
@@ -80,16 +27,15 @@ interface ThinkingBlockProps {
content: string
/** Whether the block is currently streaming */
isStreaming?: boolean
/** Persisted duration from content block */
duration?: number
/** Persisted start time from content block */
startTime?: number
/** Whether there are more content blocks after this one (e.g., tool calls) */
hasFollowingContent?: boolean
}
/**
* ThinkingBlock component displays AI reasoning/thinking process
* Shows collapsible content with duration timer
* Auto-expands during streaming and collapses when complete
* Auto-collapses when a tool call or other content comes in after it
*
* @param props - Component props
* @returns Thinking block with expandable content and timer
@@ -97,29 +43,21 @@ interface ThinkingBlockProps {
export function ThinkingBlock({
content,
isStreaming = false,
duration: persistedDuration,
startTime: persistedStartTime,
hasFollowingContent = false,
}: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(persistedDuration ?? 0)
const [duration, setDuration] = useState(0)
const userCollapsedRef = useRef<boolean>(false)
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
/**
* Updates start time reference when persisted start time changes
*/
useEffect(() => {
if (typeof persistedStartTime === 'number') {
startTimeRef.current = persistedStartTime
}
}, [persistedStartTime])
const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
/**
* Auto-expands block when streaming with content
* Auto-collapses when streaming ends
* Auto-collapses when streaming ends OR when following content arrives
*/
useEffect(() => {
if (!isStreaming) {
// Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
setIsExpanded(false)
userCollapsedRef.current = false
return
@@ -128,42 +66,57 @@ export function ThinkingBlock({
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])
}, [isStreaming, content, hasFollowingContent])
/**
* Updates duration timer during streaming
* Uses persisted duration when available
*/
// Reset start time when streaming begins
useEffect(() => {
if (typeof persistedDuration === 'number') {
setDuration(persistedDuration)
return
if (isStreaming && !hasFollowingContent) {
startTimeRef.current = Date.now()
setDuration(0)
}
}, [isStreaming, hasFollowingContent])
if (isStreaming) {
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}
// Update duration timer during streaming (stop when following content arrives)
useEffect(() => {
// Stop timer if not streaming or if there's following content (thinking is done)
if (!isStreaming || hasFollowingContent) return
setDuration(Date.now() - startTimeRef.current)
}, [isStreaming, persistedDuration])
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [isStreaming, hasFollowingContent])
// Auto-scroll to bottom during streaming using interval (same as copilot chat)
useEffect(() => {
if (!isStreaming || !isExpanded) return
const intervalId = window.setInterval(() => {
const container = scrollContainerRef.current
if (!container) return
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
}, SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded])
/**
* Formats duration in milliseconds to human-readable format
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "150ms" or "2.5s")
* Formats duration in milliseconds to seconds
* Always shows seconds, rounded to nearest whole second, minimum 1s
*/
const formatDuration = (ms: number) => {
if (ms < SECONDS_THRESHOLD) {
return `${ms}ms`
}
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = content && content.trim().length > 0
const label = isStreaming ? 'Thinking' : 'Thought'
const durationText = ` for ${formatDuration(duration)}`
return (
<div className='mt-1 mb-0'>
@@ -180,21 +133,54 @@ export function ThinkingBlock({
type='button'
disabled={!hasContent}
>
<ShimmerOverlayText
label='Thought'
value={` for ${formatDuration(duration)}`}
active={isStreaming}
/>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
<span className='text-[var(--text-muted)]'>{durationText}</span>
{isStreaming && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
{durationText}
</span>
</span>
)}
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
</span>
{hasContent && (
<ChevronUp
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
className={clsx('h-3 w-3 transition-transform', isExpanded ? 'rotate-180' : 'rotate-90')}
aria-hidden='true'
/>
)}
</button>
{isExpanded && (
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
<div
ref={scrollContainerRef}
className='ml-1 overflow-y-auto border-[var(--border-1)] border-l-2 pl-2'
style={{ maxHeight: THINKING_MAX_HEIGHT }}
>
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
{content}
{isStreaming && (

View File

@@ -201,19 +201,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'thinking') {
const isLastBlock = index === message.contentBlocks!.length - 1
// Consider the thinking block streaming if the overall message is streaming
// and the block has not been finalized with a duration yet. This avoids
// freezing the timer when new blocks are appended after the thinking block.
const isStreamingThinking = isStreaming && (block as any).duration == null
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreamingThinking}
duration={block.duration}
startTime={block.startTime}
isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent}
/>
</div>
)

View File

@@ -1,12 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import { Button, Code } from '@/components/emcn'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
import { CLASS_TOOL_METADATA, useCopilotStore } from '@/stores/panel/copilot/store'
import type { CopilotToolCall } from '@/stores/panel/copilot/types'
import type { CopilotToolCall, SubAgentContentBlock } from '@/stores/panel/copilot/types'
interface ToolCallProps {
toolCall?: CopilotToolCall
@@ -226,6 +228,213 @@ function ShimmerOverlayText({
)
}
/**
* SubAgentToolCall renders a nested tool call from a subagent in a muted/thinking style.
*/
function SubAgentToolCall({ toolCall }: { toolCall: CopilotToolCall }) {
const displayName = getDisplayNameForSubAgent(toolCall)
const isLoading =
toolCall.state === ClientToolCallState.pending ||
toolCall.state === ClientToolCallState.executing
return (
<div className='py-0.5'>
<span className='relative inline-block font-[470] font-season text-[12px] text-[var(--text-tertiary)]'>
{displayName}
{isLoading && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'subagent-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{displayName}
</span>
</span>
)}
</span>
<style>{`
@keyframes subagent-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
</div>
)
}
/**
* Get display name for subagent tool calls
*/
function getDisplayNameForSubAgent(toolCall: CopilotToolCall): string {
const fromStore = toolCall.display?.text
if (fromStore) return fromStore
const stateVerb = getStateVerb(toolCall.state)
const formattedName = formatToolName(toolCall.name)
return `${stateVerb} ${formattedName}`
}
/**
* Max height for subagent content before internal scrolling kicks in
*/
const SUBAGENT_MAX_HEIGHT = 125
/**
* Interval for auto-scroll during streaming (ms)
*/
const SUBAGENT_SCROLL_INTERVAL = 100
/**
* SubAgentContent renders the streamed content and tool calls from a subagent
* with thinking-style styling (same as ThinkingBlock).
* Auto-collapses when streaming ends and has internal scrolling for long content.
*/
function SubAgentContent({
blocks,
isStreaming = false,
}: {
blocks?: SubAgentContentBlock[]
isStreaming?: boolean
}) {
const [isExpanded, setIsExpanded] = useState(false)
const userCollapsedRef = useRef<boolean>(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Auto-expand when streaming with content, auto-collapse when done
useEffect(() => {
if (!isStreaming) {
setIsExpanded(false)
userCollapsedRef.current = false
return
}
if (!userCollapsedRef.current && blocks && blocks.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, blocks])
// Auto-scroll to bottom during streaming using interval (same as copilot chat)
useEffect(() => {
if (!isStreaming || !isExpanded) return
const intervalId = window.setInterval(() => {
const container = scrollContainerRef.current
if (!container) return
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
}, SUBAGENT_SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded])
if (!blocks || blocks.length === 0) return null
const hasContent = blocks.length > 0
const label = isStreaming ? 'Debugging' : 'Debugged'
return (
<div className='mt-1 mb-0'>
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}}
className='mb-1 inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
disabled={!hasContent}
>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
{isStreaming && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
</span>
</span>
)}
</span>
{hasContent && (
<ChevronUp
className={clsx('h-3 w-3 transition-transform', isExpanded ? 'rotate-180' : 'rotate-90')}
aria-hidden='true'
/>
)}
</button>
{isExpanded && (
<div
ref={scrollContainerRef}
className='ml-1 overflow-y-auto border-[var(--border-1)] border-l-2 pl-2'
style={{ maxHeight: SUBAGENT_MAX_HEIGHT }}
>
{blocks.map((block, index) => {
if (block.type === 'subagent_text' && block.content) {
const isLastBlock = index === blocks.length - 1
return (
<pre
key={`subagent-text-${index}`}
className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'
>
{block.content}
{isStreaming && isLastBlock && (
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
)}
</pre>
)
}
if (block.type === 'subagent_tool_call' && block.toolCall) {
return (
<SubAgentToolCall
key={`subagent-tool-${block.toolCall.id || index}`}
toolCall={block.toolCall}
/>
)
}
return null
})}
</div>
)}
</div>
)
}
/**
* Determines if a tool call is "special" and should display with gradient styling.
* Only workflow operation tools (edit, build, run, deploy) get the purple gradient.
@@ -559,6 +768,18 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
// Skip rendering some internal tools
if (toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress') return null
// Special rendering for debug tool with subagent content - only show the collapsible SubAgentContent
if (toolCall.name === 'debug' && toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0) {
return (
<div className='w-full'>
<SubAgentContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
/>
</div>
)
}
// Get current mode from store to determine if we should render integration tools
const mode = useCopilotStore.getState().mode
@@ -862,6 +1083,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
// Don't show the table if there are no inputs
if (inputEntries.length === 0) {
return null
}
return (
<div className='w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
@@ -876,14 +1102,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
</tr>
</thead>
<tbody className='bg-transparent'>
{inputEntries.length === 0 ? (
<tr className='border-[var(--border-1)] border-t bg-transparent'>
<td colSpan={2} className='px-[10px] py-[8px] text-[var(--text-muted)] text-xs'>
No inputs provided
</td>
</tr>
) : (
inputEntries.map(([key, value]) => (
{inputEntries.map(([key, value]) => (
<tr
key={key}
className='group relative border-[var(--border-1)] border-t bg-transparent'
@@ -932,8 +1151,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
</div>
</td>
</tr>
))
)}
))}
</tbody>
</table>
</div>
@@ -986,6 +1204,13 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
editedParams={editedParams}
/>
)}
{/* Render subagent content */}
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
/>
)}
</div>
)
}
@@ -1041,6 +1266,13 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
editedParams={editedParams}
/>
)}
{/* Render subagent content */}
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
/>
)}
</div>
)
}
@@ -1143,6 +1375,13 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
</Button>
</div>
) : null}
{/* Render subagent content (from debug tool or other subagents) */}
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
<SubAgentContent
blocks={toolCall.subAgentBlocks}
isStreaming={toolCall.subAgentStreaming}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { Bug, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
interface DebugArgs {
error_description: string
context?: string
}
/**
* Debug tool that spawns a subagent to diagnose workflow issues.
* This tool auto-executes and the actual work is done by the debug subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class DebugClientTool extends BaseClientTool {
static readonly id = 'debug'
constructor(toolCallId: string) {
super(toolCallId, DebugClientTool.id, DebugClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Preparing debug session', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Debugged', icon: Bug },
[ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Debug skipped', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Debug aborted', icon: XCircle },
},
}
/**
* Execute the debug tool.
* This just marks the tool as executing - the actual debug work is done server-side
* by the debug subagent, and its output is streamed as subagent events.
*/
async execute(_args?: DebugArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the debug subagent completes its work
}
}

View File

@@ -33,6 +33,7 @@ import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
@@ -78,6 +79,7 @@ try {
// Known class-based client tools: map tool name -> instantiator
const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
debug: (id) => new DebugClientTool(id),
run_workflow: (id) => new RunWorkflowClientTool(id),
get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id),
get_blocks_and_tools: (id) => new GetBlocksAndToolsClientTool(id),
@@ -120,6 +122,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
// Read-only static metadata for class-based tools (no instances)
export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefined> = {
debug: (DebugClientTool as any)?.metadata,
run_workflow: (RunWorkflowClientTool as any)?.metadata,
get_workflow_console: (GetWorkflowConsoleClientTool as any)?.metadata,
get_blocks_and_tools: (GetBlocksAndToolsClientTool as any)?.metadata,
@@ -650,6 +653,14 @@ interface StreamingContext {
newChatId?: string
doneEventCount: number
streamComplete?: boolean
/** Track active subagent sessions by parent tool call ID */
subAgentParentToolCallId?: string
/** Track subagent content per parent tool call */
subAgentContent: Record<string, string>
/** Track subagent tool calls per parent tool call */
subAgentToolCalls: Record<string, CopilotToolCall[]>
/** Track subagent streaming blocks per parent tool call */
subAgentBlocks: Record<string, any[]>
}
type SSEHandler = (
@@ -1474,6 +1485,304 @@ const sseHandlers: Record<string, SSEHandler> = {
default: () => {},
}
/**
* Helper to update a tool call with subagent data in both toolCallsById and contentBlocks
*/
function updateToolCallWithSubAgentData(
context: StreamingContext,
get: () => CopilotStore,
set: any,
parentToolCallId: string
) {
const { toolCallsById } = get()
const parentToolCall = toolCallsById[parentToolCallId]
if (!parentToolCall) {
logger.warn('[SubAgent] updateToolCallWithSubAgentData: parent tool call not found', {
parentToolCallId,
availableToolCallIds: Object.keys(toolCallsById),
})
return
}
// Prepare subagent blocks array for ordered display
const blocks = context.subAgentBlocks[parentToolCallId] || []
const updatedToolCall: CopilotToolCall = {
...parentToolCall,
subAgentContent: context.subAgentContent[parentToolCallId] || '',
subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [],
subAgentBlocks: blocks,
subAgentStreaming: true,
}
logger.info('[SubAgent] Updating tool call with subagent data', {
parentToolCallId,
parentToolName: parentToolCall.name,
subAgentContentLength: updatedToolCall.subAgentContent?.length,
subAgentBlocksCount: updatedToolCall.subAgentBlocks?.length,
subAgentToolCallsCount: updatedToolCall.subAgentToolCalls?.length,
})
// Update in toolCallsById
const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall }
set({ toolCallsById: updatedMap })
// Update in contentBlocks
let foundInContentBlocks = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === parentToolCallId) {
context.contentBlocks[i] = { ...b, toolCall: updatedToolCall }
foundInContentBlocks = true
break
}
}
if (!foundInContentBlocks) {
logger.warn('[SubAgent] Parent tool call not found in contentBlocks', {
parentToolCallId,
contentBlocksCount: context.contentBlocks.length,
toolCallBlockIds: context.contentBlocks
.filter((b: any) => b.type === 'tool_call')
.map((b: any) => b.toolCall?.id),
})
}
updateStreamingMessage(set, context)
}
/**
* SSE handlers for subagent events (events with subagent field set)
* These handle content and tool calls from subagents like debug
*/
const subAgentSSEHandlers: Record<string, SSEHandler> = {
// Handle subagent response start (ignore - just a marker)
start: () => {
// Subagent start event - no action needed, parent is already tracked from subagent_start
},
// Handle subagent text content (reasoning/thinking)
content: (data, context, get, set) => {
const parentToolCallId = context.subAgentParentToolCallId
logger.info('[SubAgent] content event', {
parentToolCallId,
hasData: !!data.data,
dataPreview: typeof data.data === 'string' ? data.data.substring(0, 50) : null,
})
if (!parentToolCallId || !data.data) {
logger.warn('[SubAgent] content missing parentToolCallId or data', {
parentToolCallId,
hasData: !!data.data,
})
return
}
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// Append content
context.subAgentContent[parentToolCallId] += data.data
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + data.data
} else {
blocks.push({
type: 'subagent_text',
content: data.data,
timestamp: Date.now(),
})
}
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
// Handle subagent reasoning (same as content for subagent display purposes)
reasoning: (data, context, get, set) => {
const parentToolCallId = context.subAgentParentToolCallId
const phase = data?.phase || data?.data?.phase
if (!parentToolCallId) return
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// For reasoning, we just append the content (treating start/end as markers)
if (phase === 'start' || phase === 'end') return
const chunk = typeof data?.data === 'string' ? data.data : data?.content || ''
if (!chunk) return
context.subAgentContent[parentToolCallId] += chunk
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + chunk
} else {
blocks.push({
type: 'subagent_text',
content: chunk,
timestamp: Date.now(),
})
}
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
// Handle subagent tool_generating (tool is being generated)
tool_generating: () => {
// Tool generating event - no action needed, we'll handle the actual tool_call
},
// Handle subagent tool calls - also execute client tools
tool_call: async (data, context, get, set) => {
const parentToolCallId = context.subAgentParentToolCallId
if (!parentToolCallId) return
const toolData = data?.data || {}
const id: string | undefined = toolData.id || data?.toolCallId
const name: string | undefined = toolData.name || data?.toolName
if (!id || !name) return
const args = toolData.arguments
// Initialize if needed
if (!context.subAgentToolCalls[parentToolCallId]) {
context.subAgentToolCalls[parentToolCallId] = []
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// Ensure client tool instance is registered (for execution)
ensureClientToolInstance(name, id)
// Create or update the subagent tool call
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex((tc) => tc.id === id)
const subAgentToolCall: CopilotToolCall = {
id,
name,
state: ClientToolCallState.pending,
...(args ? { params: args } : {}),
display: resolveToolDisplay(name, ClientToolCallState.pending, id, args),
}
if (existingIndex >= 0) {
context.subAgentToolCalls[parentToolCallId][existingIndex] = subAgentToolCall
} else {
context.subAgentToolCalls[parentToolCallId].push(subAgentToolCall)
// Also add to ordered blocks
context.subAgentBlocks[parentToolCallId].push({
type: 'subagent_tool_call',
toolCall: subAgentToolCall,
timestamp: Date.now(),
})
}
// Also add to main toolCallsById for proper tool execution
const { toolCallsById } = get()
const updated = { ...toolCallsById, [id]: subAgentToolCall }
set({ toolCallsById: updated })
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
// Execute client tools (same logic as main tool_call handler)
try {
const def = getTool(name)
if (def) {
const hasInterrupt =
typeof def.hasInterrupt === 'function' ? !!def.hasInterrupt(args || {}) : !!def.hasInterrupt
if (!hasInterrupt) {
// Auto-execute tools without interrupts
const ctx = createExecutionContext({ toolCallId: id, toolName: name })
try {
await def.execute(args || {}, ctx)
} catch (execErr: any) {
logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message })
}
}
} else {
// Fallback to class-based tools
const instance = getClientTool(id)
if (instance) {
const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
if (!hasInterruptDisplays) {
try {
await instance.execute(args)
} catch (execErr: any) {
logger.error('[SubAgent] Class tool execution failed', { id, name, error: execErr?.message })
}
}
}
}
} catch (e: any) {
logger.error('[SubAgent] Tool registry/execution error', { id, name, error: e?.message })
}
},
// Handle subagent tool results
tool_result: (data, context, get, set) => {
const parentToolCallId = context.subAgentParentToolCallId
if (!parentToolCallId) return
const toolCallId: string | undefined = data?.toolCallId || data?.data?.id
const success: boolean | undefined = data?.success !== false // Default to true if not specified
if (!toolCallId) return
// Initialize if needed
if (!context.subAgentToolCalls[parentToolCallId]) return
if (!context.subAgentBlocks[parentToolCallId]) return
// Update the subagent tool call state
const targetState = success ? ClientToolCallState.success : ClientToolCallState.error
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
(tc) => tc.id === toolCallId
)
if (existingIndex >= 0) {
const existing = context.subAgentToolCalls[parentToolCallId][existingIndex]
context.subAgentToolCalls[parentToolCallId][existingIndex] = {
...existing,
state: targetState,
display: resolveToolDisplay(existing.name, targetState, toolCallId, existing.params),
}
// Also update in ordered blocks
for (const block of context.subAgentBlocks[parentToolCallId]) {
if (block.type === 'subagent_tool_call' && block.toolCall?.id === toolCallId) {
block.toolCall = context.subAgentToolCalls[parentToolCallId][existingIndex]
break
}
}
}
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
// Handle subagent stream done - just update the streaming state
done: (data, context, get, set) => {
const parentToolCallId = context.subAgentParentToolCallId
if (!parentToolCallId) return
// Update the tool call with final content but keep streaming true until subagent_end
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
}
// Debounced UI update queue for smoother streaming
const streamingUpdateQueue = new Map<string, StreamingContext>()
let streamingUpdateRAF: number | null = null
@@ -2540,6 +2849,9 @@ export const useCopilotStore = create<CopilotStore>()(
designWorkflowContent: '',
pendingContent: '',
doneEventCount: 0,
subAgentContent: {},
subAgentToolCalls: {},
subAgentBlocks: {},
}
if (isContinuation) {
@@ -2563,6 +2875,99 @@ export const useCopilotStore = create<CopilotStore>()(
const { abortController } = get()
if (abortController?.signal.aborted) break
// Log SSE events for debugging
logger.info('[SSE] Received event', {
type: data.type,
hasSubAgent: !!data.subagent,
subagent: data.subagent,
dataPreview:
typeof data.data === 'string'
? data.data.substring(0, 100)
: JSON.stringify(data.data)?.substring(0, 100),
})
// Handle subagent_start to track parent tool call
if (data.type === 'subagent_start') {
const toolCallId = data.data?.tool_call_id
if (toolCallId) {
context.subAgentParentToolCallId = toolCallId
// Mark the parent tool call as streaming
const { toolCallsById } = get()
const parentToolCall = toolCallsById[toolCallId]
if (parentToolCall) {
const updatedToolCall: CopilotToolCall = {
...parentToolCall,
subAgentStreaming: true,
}
const updatedMap = { ...toolCallsById, [toolCallId]: updatedToolCall }
set({ toolCallsById: updatedMap })
}
logger.info('[SSE] Subagent session started', {
subagent: data.subagent,
parentToolCallId: toolCallId,
})
}
continue
}
// Handle subagent_end to finalize subagent content
if (data.type === 'subagent_end') {
const parentToolCallId = context.subAgentParentToolCallId
if (parentToolCallId) {
// Mark subagent streaming as complete
const { toolCallsById } = get()
const parentToolCall = toolCallsById[parentToolCallId]
if (parentToolCall) {
const updatedToolCall: CopilotToolCall = {
...parentToolCall,
subAgentContent: context.subAgentContent[parentToolCallId] || '',
subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [],
subAgentBlocks: context.subAgentBlocks[parentToolCallId] || [],
subAgentStreaming: false, // Done streaming
}
const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall }
set({ toolCallsById: updatedMap })
logger.info('[SSE] Subagent session ended', {
subagent: data.subagent,
parentToolCallId,
contentLength: context.subAgentContent[parentToolCallId]?.length || 0,
toolCallCount: context.subAgentToolCalls[parentToolCallId]?.length || 0,
})
}
}
context.subAgentParentToolCallId = undefined
continue
}
// Check if this is a subagent event (has subagent field)
if (data.subagent) {
const parentToolCallId = context.subAgentParentToolCallId
if (!parentToolCallId) {
logger.warn('[SSE] Subagent event without parent tool call ID', {
type: data.type,
subagent: data.subagent,
})
continue
}
logger.info('[SSE] Processing subagent event', {
type: data.type,
subagent: data.subagent,
parentToolCallId,
hasHandler: !!subAgentSSEHandlers[data.type],
})
const subAgentHandler = subAgentSSEHandlers[data.type]
if (subAgentHandler) {
await subAgentHandler(data, context, get, set)
} else {
logger.warn('[SSE] No handler for subagent event type', { type: data.type })
}
// Skip regular handlers for subagent events
if (context.streamComplete) break
continue
}
const handler = sseHandlers[data.type] || sseHandlers.default
await handler(data, context, get, set)
if (context.streamComplete) break

View File

@@ -2,12 +2,30 @@ import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools
export type ToolState = ClientToolCallState
/**
* Subagent content block for nested thinking/reasoning inside a tool call
*/
export interface SubAgentContentBlock {
type: 'subagent_text' | 'subagent_tool_call'
content?: string
toolCall?: CopilotToolCall
timestamp: number
}
export interface CopilotToolCall {
id: string
name: string
state: ClientToolCallState
params?: Record<string, any>
display?: ClientToolDisplay
/** Content streamed from a subagent (e.g., debug agent) */
subAgentContent?: string
/** Tool calls made by the subagent */
subAgentToolCalls?: CopilotToolCall[]
/** Structured content blocks for subagent (thinking + tool calls in order) */
subAgentBlocks?: SubAgentContentBlock[]
/** Whether subagent is currently streaming */
subAgentStreaming?: boolean
}
export interface MessageFileAttachment {