From 91b4d16dc5ea7fd16d54a2f2da48b481e61b1687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Apr 2026 00:04:40 +0800 Subject: [PATCH] Restore sub-agent context in the desktop chat transcript The desktop chat view flattened Agent tool activity, which made sub-agent work hard to follow and separated key evidence from the main conversation. This change threads parent tool linkage through the server bridge and desktop store, renders dispatched sub-agents as grouped cards with nested tool activity, and moves long final outputs into a markdown preview dialog so the main transcript stays readable on narrow layouts. Constraint: Existing sessions and live websocket events both needed to preserve parent-child relationships Rejected: Add brand-new subagent websocket event types | unnecessary protocol expansion when parent linkage already existed upstream Rejected: Inline full sub-agent markdown in the card body | too cramped for narrow desktop chat layouts Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep Agent card summaries compact; route long-form sub-agent output through the preview dialog unless the main chat layout is widened substantially Tested: cd desktop && bun run test -- MessageList.test.tsx chatStore.test.ts Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/sessions.test.ts -t should\ reconstruct\ parent\ agent\ tool\ linkage\ from\ parentUuid\ chains Not-tested: Full end-to-end visual verification against live CLI sessions with sub-agent text/thinking nested inline --- .../src/components/chat/MessageList.test.tsx | 41 +- desktop/src/components/chat/MessageList.tsx | 16 +- desktop/src/components/chat/ToolCallGroup.tsx | 436 +++++++++++++++++- .../components/markdown/MarkdownRenderer.tsx | 7 +- desktop/src/components/shared/Modal.tsx | 12 +- desktop/src/i18n/locales/en.ts | 7 + desktop/src/i18n/locales/zh.ts | 7 + desktop/src/stores/chatStore.test.ts | 35 ++ desktop/src/stores/chatStore.ts | 4 + desktop/src/types/chat.ts | 10 +- desktop/src/types/session.ts | 1 + src/server/__tests__/sessions.test.ts | 90 ++++ src/server/services/sessionService.ts | 96 +++- src/server/ws/events.ts | 6 +- src/server/ws/handler.ts | 16 + 15 files changed, 751 insertions(+), 33 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 818ea3c3..424acb6b 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, buildRenderItems } from './MessageList' import { useChatStore } from '../../stores/chatStore' import type { UIMessage } from '../../types/chat' @@ -132,6 +132,45 @@ describe('MessageList nested tool calls', () => { expect(screen.getByText('Explore agent unavailable in this session')).toBeTruthy() }) + it('shows completed agent output when no nested tool activity is available', () => { + const longResult = '探索完成。让我将结果整合写入计划文件。第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。' + + useChatStore.setState({ + messages: [ + { + id: 'tool-agent', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-1', + input: { description: '探索整体架构' }, + timestamp: 1, + }, + { + id: 'result-agent', + type: 'tool_result', + toolUseId: 'agent-1', + content: { + status: 'completed', + content: [{ type: 'text', text: longResult }], + }, + isError: false, + timestamp: 2, + }, + ], + }) + + render() + + expect(screen.getByText('Done')).toBeTruthy() + expect(screen.getByRole('button', { name: 'View result' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'View result' })) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText(/第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。/)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy() + }) + it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.assign(navigator, { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index df3fa901..c539a56f 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -21,7 +21,7 @@ type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } | { kind: 'message'; message: UIMessage } -function buildRenderItems(messages: UIMessage[], toolUseIds: Set): RenderItem[] { +export function buildRenderItems(messages: UIMessage[], toolUseIds: Set): RenderItem[] { const items: RenderItem[] = [] let pendingToolCalls: ToolCall[] = [] @@ -42,6 +42,10 @@ function buildRenderItems(messages: UIMessage[], toolUseIds: Set): Rende } if (msg.type === 'tool_use') { + if (msg.parentToolUseId) { + flushGroup() + continue + } if (msg.toolName === 'AskUserQuestion') { flushGroup() items.push({ kind: 'message', message: msg }) @@ -68,10 +72,19 @@ export function MessageList() { const toolUseIds = new Set() const toolResultMap = new Map() + const childToolCallsByParent = new Map() for (const msg of messages) { if (msg.type === 'tool_use') { toolUseIds.add(msg.toolUseId) + if (msg.parentToolUseId) { + const siblings = childToolCallsByParent.get(msg.parentToolUseId) + if (siblings) { + siblings.push(msg) + } else { + childToolCallsByParent.set(msg.parentToolUseId, [msg]) + } + } } if (msg.type === 'tool_result' && msg.toolUseId) { toolResultMap.set(msg.toolUseId, msg) @@ -90,6 +103,7 @@ export function MessageList() { key={item.id} toolCalls={item.toolCalls} resultMap={toolResultMap} + childToolCallsByParent={childToolCallsByParent} isStreaming={ chatState === 'tool_executing' && item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId)) diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 0cf8c17b..ed8a32d7 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { ToolCallBlock } from './ToolCallBlock' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' +import { Modal } from '../shared/Modal' import { useTranslation } from '../../i18n' import type { UIMessage } from '../../types/chat' @@ -9,6 +11,7 @@ type ToolResult = Extract type Props = { toolCalls: ToolCall[] resultMap: Map + childToolCallsByParent: Map /** When true, the last tool is still executing — show expanded */ isStreaming?: boolean } @@ -47,30 +50,122 @@ function groupHasErrors(toolCalls: ToolCall[], resultMap: Map toolCall.toolName === 'Agent') + + if (allAgents) { return ( - ) } - return + // Single tool call — render directly without group wrapper + if (toolCalls.length === 1) { + const tc = toolCalls[0]! + return ( + + ) + } + + return ( + + ) +} + +function AgentToolGroup({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { + const [expanded, setExpanded] = useState(true) + const t = useTranslation() + const errorPresent = groupHasErrors(toolCalls, resultMap) + const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) + + useEffect(() => { + if (isStreaming) { + setExpanded(true) + } + }, [isStreaming]) + + return ( +
+ + + {expanded && ( +
+
+
+ {toolCalls.map((toolCall) => ( +
+
+
+
+
+ +
+ ))} +
+
+ )} +
+ ) } /** Separated so the useState hook is never called conditionally. */ -function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) { +function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) { const [expanded, setExpanded] = useState(false) const t = useTranslation() const summary = generateSummary(toolCalls, t) const errorPresent = groupHasErrors(toolCalls, resultMap) const allComplete = toolCalls.every((tc) => resultMap.has(tc.toolUseId)) + const hasNestedToolCalls = toolCalls.some((tc) => (childToolCallsByParent.get(tc.toolUseId)?.length ?? 0) > 0) + + useEffect(() => { + if (isStreaming || hasNestedToolCalls) { + setExpanded(true) + } + }, [hasNestedToolCalls, isStreaming]) return (
@@ -102,13 +197,12 @@ function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) { {expanded && (
{toolCalls.map((tc) => { - const result = resultMap.get(tc.toolUseId) return ( - ) @@ -118,3 +212,311 @@ function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) {
) } + +function AgentCallCard({ + toolCall, + resultMap, + childToolCallsByParent, + isStreaming = false, +}: { + toolCall: ToolCall + resultMap: Map + childToolCallsByParent: Map + isStreaming?: boolean +}) { + const [expanded, setExpanded] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) + const t = useTranslation() + const input = toolCall.input && typeof toolCall.input === 'object' + ? toolCall.input as Record + : {} + const result = resultMap.get(toolCall.toolUseId) + const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] + const recentToolCalls = childToolCalls.slice(-2) + const status = getAgentStatus({ + hasResult: !!result, + isError: !!result?.isError, + isStreaming, + childCount: childToolCalls.length, + }) + const statusClassName = getAgentStatusClassName(status) + const statusLabel = getAgentStatusLabel(status, t) + const errorText = result?.isError ? getAgentErrorSummary(result.content) : '' + const fullOutputText = result && !result.isError ? extractTextContent(result.content).trim() : '' + const outputSummary = fullOutputText ? getAgentOutputSummary(fullOutputText) : '' + const description = typeof input.description === 'string' ? input.description : '' + + return ( +
+
+ smart_toy +
+
+ Agent + {description && ( + + {description} + + )} +
+ {!expanded && recentToolCalls.length > 0 && ( +
+ {recentToolCalls.map((recentToolCall) => ( +
+ {formatRecentToolUseSummary(recentToolCall, resultMap)} +
+ ))} +
+ )} + {!expanded && !recentToolCalls.length && errorText && ( +
+ {errorText} +
+ )} + {!expanded && !recentToolCalls.length && !errorText && outputSummary && ( +
+ {outputSummary} +
+ )} +
+ {outputSummary && ( + + )} + + {statusLabel} + + +
+ + {expanded && ( +
+ {errorText && ( +
+ {errorText} +
+ )} + {childToolCalls.length > 0 ? ( +
+ {childToolCalls.map((childToolCall) => ( + + ))} +
+ ) : outputSummary ? ( +
+
+ {outputSummary} +
+
+ + {t('agentStatus.viewResult')} + +
+
+ ) : ( +
+ {status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')} +
+ )} +
+ )} + setPreviewOpen(false)} + title={description || t('agentStatus.resultTitle')} + width={900} + > +
+ +
+
+
+ ) +} + +function ToolCallTree({ + toolCall, + resultMap, + childToolCallsByParent, + compact = false, +}: { + toolCall: ToolCall + resultMap: Map + childToolCallsByParent: Map + compact?: boolean +}) { + const result = resultMap.get(toolCall.toolUseId) + const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? [] + + return ( +
+ + {childToolCalls.length > 0 && ( +
+
+ {childToolCalls.map((childToolCall) => ( + + ))} +
+
+ )} +
+ ) +} + +type AgentStatus = 'starting' | 'running' | 'done' | 'failed' + +function getAgentStatus({ + hasResult, + isError, + isStreaming, + childCount, +}: { + hasResult: boolean + isError: boolean + isStreaming: boolean + childCount: number +}): AgentStatus { + if (hasResult && isError) return 'failed' + if (hasResult) return 'done' + if (isStreaming || childCount > 0) return 'running' + return 'starting' +} + +function getAgentStatusLabel( + status: AgentStatus, + t: (key: any, params?: any) => string, +): string { + switch (status) { + case 'failed': + return t('agentStatus.failed') + case 'done': + return t('agentStatus.done') + case 'running': + return t('agentStatus.running') + case 'starting': + default: + return t('agentStatus.starting') + } +} + +function getAgentStatusClassName(status: AgentStatus): string { + switch (status) { + case 'failed': + return 'bg-[var(--color-error)]/10 text-[var(--color-error)]' + case 'done': + return 'bg-[var(--color-success)]/10 text-[var(--color-success)]' + case 'running': + return 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]' + case 'starting': + default: + return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)]' + } +} + +function formatRecentToolUseSummary( + toolCall: ToolCall, + resultMap: Map, +): string { + const input = toolCall.input && typeof toolCall.input === 'object' + ? toolCall.input as Record + : {} + const result = resultMap.get(toolCall.toolUseId) + const suffix = result?.isError ? ' • failed' : result ? ' • done' : ' • running' + + switch (toolCall.toolName) { + case 'Bash': + return `Bash · ${typeof input.command === 'string' ? input.command : ''}${suffix}` + case 'Read': + return `Read · ${typeof input.file_path === 'string' ? input.file_path.split('/').pop() : 'file'}${suffix}` + case 'Glob': + return `Glob · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}` + case 'Grep': + return `Grep · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}` + case 'Agent': + return `Agent · ${typeof input.description === 'string' ? input.description : ''}${suffix}` + default: + return `${toolCall.toolName}${suffix}` + } +} + +function getAgentErrorSummary(content: unknown): string { + const text = extractTextContent(content).replace(/\s+/g, ' ').trim() + if (!text) return '' + if (text.includes(`Agent type 'Explore' not found`)) { + return 'Explore agent unavailable in this session' + } + return text.length > 120 ? `${text.slice(0, 120)}...` : text +} + +function getAgentOutputSummary(content: string): string { + const text = content.replace(/\s+\n/g, '\n').trim() + if (!text) return '' + return text.length > 220 ? `${text.slice(0, 220)}...` : text +} + +function extractTextContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((chunk) => { + if (typeof chunk === 'string') return chunk + if (chunk && typeof chunk === 'object' && 'text' in chunk) { + return typeof chunk.text === 'string' ? chunk.text : '' + } + return '' + }) + .filter(Boolean) + .join('\n') + } + if (content && typeof content === 'object') { + if ( + 'status' in content && + (content as Record).status === 'completed' && + Array.isArray((content as Record).content) + ) { + return extractTextContent((content as Record).content) + } + } + if (content && typeof content === 'object') { + return JSON.stringify(content) + } + return '' +} diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index a0d2eb1d..fd26e457 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -90,15 +90,16 @@ export function MarkdownRenderer({ content }: Props) { const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)] prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold prose-p:my-2 prose-p:leading-relaxed + prose-p:break-words prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline prose-strong:text-[var(--color-text-primary)] prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 - prose-table:text-sm - prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 - prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)]` + prose-table:w-full prose-table:table-auto prose-table:text-sm + prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 prose-th:whitespace-normal prose-th:break-words prose-th:align-top + prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)] prose-td:whitespace-normal prose-td:break-words prose-td:align-top` if (codeBlocks.length === 0) { return ( diff --git a/desktop/src/components/shared/Modal.tsx b/desktop/src/components/shared/Modal.tsx index ad368308..a74a79a6 100644 --- a/desktop/src/components/shared/Modal.tsx +++ b/desktop/src/components/shared/Modal.tsx @@ -33,10 +33,20 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
{title && ( -
+

{title}

+
)} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 842ac821..7d22245a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -228,6 +228,13 @@ export const en = { 'toolGroup.searchedMany': 'searched {count} patterns', 'toolGroup.agentOne': 'dispatched an agent', 'toolGroup.agentMany': 'dispatched {count} agents', + 'agentStatus.starting': 'Starting', + 'agentStatus.running': 'Running', + 'agentStatus.done': 'Done', + 'agentStatus.failed': 'Failed', + 'agentStatus.noActivity': 'No tool activity yet', + 'agentStatus.viewResult': 'View result', + 'agentStatus.resultTitle': 'Agent result', 'toolGroup.searchedWeb': 'searched the web', 'toolGroup.fetchedOne': 'fetched a page', 'toolGroup.fetchedMany': 'fetched {count} pages', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 295f08bf..9f5fd3db 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -230,6 +230,13 @@ export const zh: Record = { 'toolGroup.searchedMany': '搜索了 {count} 个模式', 'toolGroup.agentOne': '派遣了一个代理', 'toolGroup.agentMany': '派遣了 {count} 个代理', + 'agentStatus.starting': '开始中', + 'agentStatus.running': '进行中', + 'agentStatus.done': '完成', + 'agentStatus.failed': '失败', + 'agentStatus.noActivity': '暂时还没有工具活动', + 'agentStatus.viewResult': '查看结果', + 'agentStatus.resultTitle': 'Agent 结果', 'toolGroup.searchedWeb': '搜索了网页', 'toolGroup.fetchedOne': '获取了一个页面', 'toolGroup.fetchedMany': '获取了 {count} 个页面', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 01eb1d72..f7fc8a3b 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -64,6 +64,7 @@ describe('chatStore history mapping', () => { type: 'assistant', timestamp: '2026-04-06T00:00:00.000Z', model: 'opus', + parentToolUseId: 'agent-1', content: [ { type: 'thinking', thinking: 'internal reasoning' }, { type: 'text', text: '目录结构分析' }, @@ -74,6 +75,7 @@ describe('chatStore history mapping', () => { id: 'user-1', type: 'user', timestamp: '2026-04-06T00:00:01.000Z', + parentToolUseId: 'agent-1', content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok', is_error: false }, ], @@ -88,6 +90,39 @@ describe('chatStore history mapping', () => { 'tool_use', 'tool_result', ]) + expect(mapped[2]).toMatchObject({ parentToolUseId: 'agent-1' }) + expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' }) + }) + + it('keeps parent tool linkage for live tool events', () => { + useChatStore.getState().handleServerMessage({ + type: 'tool_use_complete', + toolName: 'Read', + toolUseId: 'tool-1', + input: { file_path: 'src/App.tsx' }, + parentToolUseId: 'agent-1', + }) + + useChatStore.getState().handleServerMessage({ + type: 'tool_result', + toolUseId: 'tool-1', + content: 'ok', + isError: false, + parentToolUseId: 'agent-1', + }) + + expect(useChatStore.getState().messages).toMatchObject([ + { + type: 'tool_use', + toolUseId: 'tool-1', + parentToolUseId: 'agent-1', + }, + { + type: 'tool_result', + toolUseId: 'tool-1', + parentToolUseId: 'agent-1', + }, + ]) }) it('sends permission mode updates to the active session only', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index abfc172f..ba9ef9de 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -334,6 +334,7 @@ export const useChatStore = create((set, get) => ({ toolUseId: msg.toolUseId || s.activeToolUseId || '', input: msg.input, timestamp: Date.now(), + parentToolUseId: msg.parentToolUseId, }], activeToolUseId: null, activeToolName: null, @@ -361,6 +362,7 @@ export const useChatStore = create((set, get) => ({ content: msg.content, isError: msg.isError, timestamp: Date.now(), + parentToolUseId: msg.parentToolUseId, }], chatState: 'thinking', activeThinkingId: null, @@ -532,6 +534,7 @@ export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMess toolUseId: block.id ?? '', input: block.input, timestamp, + parentToolUseId: msg.parentToolUseId, }) } } @@ -567,6 +570,7 @@ export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMess content: block.content, isError: !!block.is_error, timestamp, + parentToolUseId: msg.parentToolUseId, }) } } diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index d01b2a82..261d9cba 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -30,10 +30,10 @@ export type UIAttachment = { export type ServerMessage = | { type: 'connected'; sessionId: string } - | { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string } + | { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string; parentToolUseId?: string } | { type: 'content_delta'; text?: string; toolInput?: string } - | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown } - | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean } + | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string } + | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string } | { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } @@ -76,8 +76,8 @@ export type UIMessage = | { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[] } | { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string } | { id: string; type: 'thinking'; content: string; timestamp: number } - | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number } - | { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number } + | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string } + | { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string } | { id: string; type: 'system'; content: string; timestamp: number } | { id: string; type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string; timestamp: number } | { id: string; type: 'error'; message: string; code: string; timestamp: number } diff --git a/desktop/src/types/session.ts b/desktop/src/types/session.ts index e8584e15..96a7e609 100644 --- a/desktop/src/types/session.ts +++ b/desktop/src/types/session.ts @@ -18,6 +18,7 @@ export type MessageEntry = { timestamp: string model?: string parentUuid?: string + parentToolUseId?: string isSidechain?: boolean } diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 146284bd..95991cde 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -246,6 +246,96 @@ describe('SessionService', () => { expect(messages).toHaveLength(2) }) + it('should reconstruct parent agent tool linkage from parentUuid chains', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const userUuid = crypto.randomUUID() + const agentAssistantUuid = crypto.randomUUID() + const childAssistantUuid = crypto.randomUUID() + + await writeSessionFile('-tmp-project', sessionId, [ + makeSnapshotEntry(), + makeUserEntry('Inspect the codebase', userUuid), + { + parentUuid: userUuid, + isSidechain: false, + type: 'assistant', + message: { + model: 'claude-opus-4-6', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Agent', + id: 'agent-tool-1', + input: { description: 'Inspect src/components' }, + }, + ], + }, + uuid: agentAssistantUuid, + timestamp: '2026-01-01T00:02:00.000Z', + }, + { + parentUuid: agentAssistantUuid, + isSidechain: true, + type: 'assistant', + message: { + model: 'claude-opus-4-6', + id: `msg_${crypto.randomUUID().slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', + id: 'read-tool-1', + input: { file_path: 'src/components/App.tsx' }, + }, + ], + }, + uuid: childAssistantUuid, + timestamp: '2026-01-01T00:02:30.000Z', + }, + { + parentUuid: childAssistantUuid, + isSidechain: true, + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'read-tool-1', + content: 'ok', + is_error: false, + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:03:00.000Z', + userType: 'external', + cwd: '/tmp/test', + sessionId: 'test-session', + }, + ]) + + const messages = await service.getSessionMessages(sessionId) + + expect(messages[1]).toMatchObject({ + type: 'tool_use', + parentToolUseId: undefined, + }) + expect(messages[2]).toMatchObject({ + type: 'tool_use', + parentToolUseId: 'agent-tool-1', + }) + expect(messages[3]).toMatchObject({ + type: 'tool_result', + parentToolUseId: 'agent-tool-1', + }) + }) + it('should recover workDir from session-meta entries', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index b76f6db0..640c7f14 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -44,6 +44,7 @@ export type MessageEntry = { timestamp: string model?: string parentUuid?: string + parentToolUseId?: string isSidechain?: boolean } @@ -52,6 +53,7 @@ type RawEntry = { type?: string uuid?: string parentUuid?: string | null + parent_tool_use_id?: string | null isSidechain?: boolean isMeta?: boolean cwd?: string @@ -150,7 +152,10 @@ export class SessionService { // Entry → MessageEntry conversion // -------------------------------------------------------------------------- - private entryToMessage(entry: RawEntry): MessageEntry | null { + private entryToMessage( + entry: RawEntry, + parentToolUseId?: string, + ): MessageEntry | null { const msg = entry.message if (!msg || !msg.role) return null @@ -193,10 +198,83 @@ export class SessionService { timestamp: entry.timestamp || new Date().toISOString(), model: msg.model, parentUuid: entry.parentUuid ?? undefined, + parentToolUseId, isSidechain: entry.isSidechain, } } + private extractAgentToolUseId(entry: RawEntry): string | undefined { + const content = entry.message?.content + if (!Array.isArray(content)) return undefined + + for (const block of content as Array>) { + if ( + block.type === 'tool_use' && + block.name === 'Agent' && + typeof block.id === 'string' + ) { + return block.id + } + } + + return undefined + } + + private resolveParentToolUseId( + entry: RawEntry, + entriesByUuid: Map, + cache: Map, + ): string | undefined { + if ( + typeof entry.parent_tool_use_id === 'string' && + entry.parent_tool_use_id.length > 0 + ) { + return entry.parent_tool_use_id + } + + if (entry.isSidechain !== true) { + return undefined + } + + const cacheKey = entry.uuid + if (cacheKey && cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + let resolved: string | undefined + let currentParentUuid = + typeof entry.parentUuid === 'string' ? entry.parentUuid : undefined + const visited = new Set() + + while (currentParentUuid && !visited.has(currentParentUuid)) { + visited.add(currentParentUuid) + const parentEntry = entriesByUuid.get(currentParentUuid) + if (!parentEntry) break + + const directAgentToolUseId = this.extractAgentToolUseId(parentEntry) + if (directAgentToolUseId) { + resolved = directAgentToolUseId + break + } + + if (parentEntry.uuid && cache.has(parentEntry.uuid)) { + resolved = cache.get(parentEntry.uuid) + break + } + + currentParentUuid = + typeof parentEntry.parentUuid === 'string' + ? parentEntry.parentUuid + : undefined + } + + if (cacheKey) { + cache.set(cacheKey, resolved) + } + + return resolved + } + // -------------------------------------------------------------------------- // Title extraction // -------------------------------------------------------------------------- @@ -651,6 +729,15 @@ export class SessionService { private entriesToMessages(entries: RawEntry[]): MessageEntry[] { const messages: MessageEntry[] = [] + const entriesByUuid = new Map() + const parentToolUseIdCache = new Map() + + for (const entry of entries) { + if (typeof entry.uuid === 'string' && entry.uuid.length > 0) { + entriesByUuid.set(entry.uuid, entry) + } + } + for (const entry of entries) { // Only process transcript entries (user / assistant / system with messages) if (!entry.message?.role) continue @@ -668,7 +755,12 @@ export class SessionService { continue } - const msg = this.entryToMessage(entry) + const parentToolUseId = this.resolveParentToolUseId( + entry, + entriesByUuid, + parentToolUseIdCache, + ) + const msg = this.entryToMessage(entry, parentToolUseId) if (msg) { messages.push(msg) } diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index 93431d9f..508740ec 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -29,10 +29,10 @@ export type AttachmentRef = { export type ServerMessage = | { type: 'connected'; sessionId: string } - | { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string } + | { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string; parentToolUseId?: string } | { type: 'content_delta'; text?: string; toolInput?: string } - | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown } - | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean } + | { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown; parentToolUseId?: string } + | { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; parentToolUseId?: string } | { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 46966cc7..dcb27a5c 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -377,6 +377,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { toolName: block.name, toolUseId: block.id, input: block.input, + parentToolUseId: + typeof cliMsg.parent_tool_use_id === 'string' + ? cliMsg.parent_tool_use_id + : undefined, }) } } @@ -402,6 +406,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { toolUseId: block.tool_use_id, content: block.content, isError: !!block.is_error, + parentToolUseId: + typeof cliMsg.parent_tool_use_id === 'string' + ? cliMsg.parent_tool_use_id + : undefined, }) } } @@ -439,6 +447,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { blockType: 'tool_use', toolName: contentBlock.name, toolUseId: contentBlock.id, + parentToolUseId: + typeof cliMsg.parent_tool_use_id === 'string' + ? cliMsg.parent_tool_use_id + : undefined, }] } return [{ type: 'content_start', blockType: 'text' }] @@ -480,6 +492,10 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] { toolName: toolBlock.toolName, toolUseId: toolBlock.toolUseId, input: parsedInput, + parentToolUseId: + typeof cliMsg.parent_tool_use_id === 'string' + ? cliMsg.parent_tool_use_id + : undefined, }] } }