import { extractThinking, extractToolUse } from './message-utils'; import type { RawMessage, ToolStatus } from '@/stores/chat'; export type TaskStepStatus = 'running' | 'completed' | 'error'; export interface TaskStep { id: string; label: string; status: TaskStepStatus; kind: 'thinking' | 'tool' | 'system'; detail?: string; depth: number; parentId?: string; } const MAX_TASK_STEPS = 8; interface DeriveTaskStepsInput { messages: RawMessage[]; streamingMessage: unknown | null; streamingTools: ToolStatus[]; sending: boolean; pendingFinal: boolean; showThinking: boolean; } export interface SubagentCompletionInfo { sessionKey: string; sessionId: string; agentId: string; } function normalizeText(text: string | null | undefined): string | undefined { if (!text) return undefined; const normalized = text.replace(/\s+/g, ' ').trim(); if (!normalized) return undefined; return normalized; } function makeToolId(prefix: string, name: string, index: number): string { return `${prefix}:${name}:${index}`; } export function parseAgentIdFromSessionKey(sessionKey: string): string | null { const parts = sessionKey.split(':'); if (parts.length < 2 || parts[0] !== 'agent') return null; return parts[1] || null; } export function parseSubagentCompletionInfo(message: RawMessage): SubagentCompletionInfo | null { const text = typeof message.content === 'string' ? message.content : Array.isArray(message.content) ? message.content.map((block) => ('text' in block && typeof block.text === 'string' ? block.text : '')).join('\n') : ''; if (!text.includes('[Internal task completion event]')) return null; const sessionKeyMatch = text.match(/session_key:\s*(.+)/); const sessionIdMatch = text.match(/session_id:\s*(.+)/); const sessionKey = sessionKeyMatch?.[1]?.trim(); const sessionId = sessionIdMatch?.[1]?.trim(); if (!sessionKey || !sessionId) return null; const agentId = parseAgentIdFromSessionKey(sessionKey); if (!agentId) return null; return { sessionKey, sessionId, agentId }; } function isSpawnLikeStep(label: string): boolean { return /(spawn|subagent|delegate|parallel)/i.test(label); } function tryParseJsonObject(detail: string | undefined): Record | null { if (!detail) return null; try { const parsed = JSON.parse(detail) as unknown; return parsed && typeof parsed === 'object' ? parsed as Record : null; } catch { return null; } } function extractBranchAgent(step: TaskStep): string | null { const parsed = tryParseJsonObject(step.detail); const agentId = parsed?.agentId; if (typeof agentId === 'string' && agentId.trim()) return agentId.trim(); const message = typeof parsed?.message === 'string' ? parsed.message : step.detail; if (!message) return null; const match = message.match(/\b(coder|reviewer|project-manager|manager|planner|researcher|worker|subagent)\b/i); return match ? match[1] : null; } function attachTopology(steps: TaskStep[]): TaskStep[] { const withTopology: TaskStep[] = []; let activeBranchNodeId: string | null = null; for (const step of steps) { if (step.kind === 'system') { activeBranchNodeId = null; withTopology.push({ ...step, depth: 1, parentId: 'agent-run' }); continue; } if (/sessions_spawn/i.test(step.label)) { const branchAgent = extractBranchAgent(step) || 'subagent'; const branchNodeId = `${step.id}:branch`; withTopology.push({ ...step, depth: 1, parentId: 'agent-run' }); withTopology.push({ id: branchNodeId, label: `${branchAgent} run`, status: step.status, kind: 'system', detail: `Spawned branch for ${branchAgent}`, depth: 2, parentId: step.id, }); activeBranchNodeId = branchNodeId; continue; } if (/sessions_yield/i.test(step.label)) { withTopology.push({ ...step, depth: activeBranchNodeId ? 3 : 1, parentId: activeBranchNodeId ?? 'agent-run', }); activeBranchNodeId = null; continue; } if (step.kind === 'thinking') { withTopology.push({ ...step, depth: activeBranchNodeId ? 3 : 1, parentId: activeBranchNodeId ?? 'agent-run', }); continue; } if (isSpawnLikeStep(step.label)) { activeBranchNodeId = step.id; withTopology.push({ ...step, depth: 1, parentId: 'agent-run', }); continue; } withTopology.push({ ...step, depth: activeBranchNodeId ? 3 : 1, parentId: activeBranchNodeId ?? 'agent-run', }); } return withTopology; } export function deriveTaskSteps({ messages, streamingMessage, streamingTools, sending, pendingFinal, showThinking, }: DeriveTaskStepsInput): TaskStep[] { const steps: TaskStep[] = []; const stepIndexById = new Map(); const upsertStep = (step: TaskStep): void => { const existingIndex = stepIndexById.get(step.id); if (existingIndex == null) { stepIndexById.set(step.id, steps.length); steps.push(step); return; } const existing = steps[existingIndex]; steps[existingIndex] = { ...existing, ...step, detail: step.detail ?? existing.detail, }; }; const streamMessage = streamingMessage && typeof streamingMessage === 'object' ? streamingMessage as RawMessage : null; const relevantAssistantMessages = messages.filter((message) => { if (!message || message.role !== 'assistant') return false; if (extractToolUse(message).length > 0) return true; return showThinking && !!extractThinking(message); }); for (const [messageIndex, assistantMessage] of relevantAssistantMessages.entries()) { if (showThinking) { const thinking = extractThinking(assistantMessage); if (thinking) { upsertStep({ id: `history-thinking-${assistantMessage.id || messageIndex}`, label: 'Thinking', status: 'completed', kind: 'thinking', detail: normalizeText(thinking), depth: 1, }); } } extractToolUse(assistantMessage).forEach((tool, index) => { upsertStep({ id: tool.id || makeToolId(`history-tool-${assistantMessage.id || messageIndex}`, tool.name, index), label: tool.name, status: 'completed', kind: 'tool', detail: normalizeText(JSON.stringify(tool.input, null, 2)), depth: 1, }); }); } if (streamMessage && showThinking) { const thinking = extractThinking(streamMessage); if (thinking) { upsertStep({ id: 'stream-thinking', label: 'Thinking', status: 'running', kind: 'thinking', detail: normalizeText(thinking), depth: 1, }); } } const activeToolIds = new Set(); const activeToolNamesWithoutIds = new Set(); streamingTools.forEach((tool, index) => { const id = tool.toolCallId || tool.id || makeToolId('stream-status', tool.name, index); activeToolIds.add(id); if (!tool.toolCallId && !tool.id) { activeToolNamesWithoutIds.add(tool.name); } upsertStep({ id, label: tool.name, status: tool.status, kind: 'tool', detail: normalizeText(tool.summary), depth: 1, }); }); if (streamMessage) { extractToolUse(streamMessage).forEach((tool, index) => { const id = tool.id || makeToolId('stream-tool', tool.name, index); if (activeToolIds.has(id) || activeToolNamesWithoutIds.has(tool.name)) return; upsertStep({ id, label: tool.name, status: 'running', kind: 'tool', detail: normalizeText(JSON.stringify(tool.input, null, 2)), depth: 1, }); }); } if (sending && pendingFinal) { upsertStep({ id: 'system-finalizing', label: 'Finalizing answer', status: 'running', kind: 'system', detail: 'Waiting for the assistant to finish this run.', depth: 1, }); } else if (sending && steps.length === 0) { upsertStep({ id: 'system-preparing', label: 'Preparing run', status: 'running', kind: 'system', detail: 'Waiting for the first streaming update.', depth: 1, }); } const withTopology = attachTopology(steps); return withTopology.length > MAX_TASK_STEPS ? withTopology.slice(-MAX_TASK_STEPS) : withTopology; }