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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 00:04:40 +08:00
parent bfd2405857
commit 91b4d16dc5
15 changed files with 751 additions and 33 deletions

View File

@ -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(<MessageList />)
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, {

View File

@ -21,7 +21,7 @@ type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
| { kind: 'message'; message: UIMessage }
function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>): RenderItem[] {
export function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>): RenderItem[] {
const items: RenderItem[] = []
let pendingToolCalls: ToolCall[] = []
@ -42,6 +42,10 @@ function buildRenderItems(messages: UIMessage[], toolUseIds: Set<string>): 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<string>()
const toolResultMap = new Map<string, ToolResult>()
const childToolCallsByParent = new Map<string, ToolCall[]>()
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))

View File

@ -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<UIMessage, { type: 'tool_result' }>
type Props = {
toolCalls: ToolCall[]
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
/** When true, the last tool is still executing — show expanded */
isStreaming?: boolean
}
@ -47,30 +50,122 @@ function groupHasErrors(toolCalls: ToolCall[], resultMap: Map<string, ToolResult
})
}
export function ToolCallGroup({ toolCalls, resultMap, isStreaming }: Props) {
// Single tool call — render directly without group wrapper
if (toolCalls.length === 1) {
const tc = toolCalls[0]!
const result = resultMap.get(tc.toolUseId)
export function ToolCallGroup({ toolCalls, resultMap, childToolCallsByParent, isStreaming }: Props) {
const allAgents = toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
if (allAgents) {
return (
<ToolCallBlock
toolName={tc.toolName}
input={tc.input}
result={result ? { content: result.content, isError: result.isError } : null}
<AgentToolGroup
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
)
}
return <ToolCallGroupMulti toolCalls={toolCalls} resultMap={resultMap} isStreaming={isStreaming} />
// Single tool call — render directly without group wrapper
if (toolCalls.length === 1) {
const tc = toolCalls[0]!
return (
<ToolCallTree
toolCall={tc}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
/>
)
}
return (
<ToolCallGroupMulti
toolCalls={toolCalls}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming}
/>
)
}
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 (
<div className="mb-2 ml-10">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
{toolCalls.length === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: toolCalls.length })}
</span>
{isStreaming && (
<span className="rounded-full bg-[var(--color-warning)]/12 px-2 py-0.5 text-[10px] font-semibold text-[var(--color-warning)]">
{t('agentStatus.running')}
</span>
)}
{!isStreaming && errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
)}
{!isStreaming && !errorPresent && allComplete && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
)}
{!isStreaming && !errorPresent && !allComplete && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">pending</span>
)}
</button>
{expanded && (
<div className="relative mt-3 pl-5">
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]/45" />
<div className="space-y-2">
{toolCalls.map((toolCall) => (
<div key={toolCall.id} className="relative pl-7">
<div className="absolute left-0 top-1/2 -translate-y-1/2">
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]/45" />
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
</div>
<AgentCallCard
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
isStreaming={isStreaming && !resultMap.has(toolCall.toolUseId)}
/>
</div>
))}
</div>
</div>
)}
</div>
)
}
/** 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 (
<div className="mb-2 ml-10">
@ -102,13 +197,12 @@ function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) {
{expanded && (
<div className="mt-1.5 space-y-1">
{toolCalls.map((tc) => {
const result = resultMap.get(tc.toolUseId)
return (
<ToolCallBlock
<ToolCallTree
key={tc.id}
toolName={tc.toolName}
input={tc.input}
result={result ? { content: result.content, isError: result.isError } : null}
toolCall={tc}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
)
@ -118,3 +212,311 @@ function ToolCallGroupMulti({ toolCalls, resultMap, isStreaming }: Props) {
</div>
)
}
function AgentCallCard({
toolCall,
resultMap,
childToolCallsByParent,
isStreaming = false,
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
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<string, unknown>
: {}
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 (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)]">
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50">
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">Agent</span>
{description && (
<span className="truncate text-[12px] text-[var(--color-text-secondary)]">
{description}
</span>
)}
</div>
{!expanded && recentToolCalls.length > 0 && (
<div className="mt-1 space-y-1">
{recentToolCalls.map((recentToolCall) => (
<div
key={recentToolCall.id}
className="truncate text-[11px] text-[var(--color-text-tertiary)]"
>
{formatRecentToolUseSummary(recentToolCall, resultMap)}
</div>
))}
</div>
)}
{!expanded && !recentToolCalls.length && errorText && (
<div className="mt-1 truncate text-[11px] text-[var(--color-error)]">
{errorText}
</div>
)}
{!expanded && !recentToolCalls.length && !errorText && outputSummary && (
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
{outputSummary}
</div>
)}
</div>
{outputSummary && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setPreviewOpen(true)
}}
className="shrink-0 rounded-md border border-[var(--color-border)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{t('agentStatus.viewResult')}
</button>
)}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusClassName}`}>
{statusLabel}
</span>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[var(--color-outline)] transition-colors hover:bg-[var(--color-surface-hover)]"
aria-label={expanded ? 'Collapse agent' : 'Expand agent'}
>
<span className="material-symbols-outlined text-[16px]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
</button>
</div>
{expanded && (
<div className="border-t border-[var(--color-border)]/60 px-3 py-3">
{errorText && (
<div className="mb-3 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60 px-3 py-2 text-[11px] text-[var(--color-error)]">
{errorText}
</div>
)}
{childToolCalls.length > 0 ? (
<div className="space-y-1">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
))}
</div>
) : outputSummary ? (
<div className="rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-3 py-3">
<div className="line-clamp-3 text-[11px] leading-[1.55] text-[var(--color-text-secondary)]">
{outputSummary}
</div>
<div className="mt-3 flex justify-end">
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{t('agentStatus.viewResult')}
</span>
</div>
</div>
) : (
<div className="text-[11px] text-[var(--color-text-tertiary)]">
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
</div>
)}
</div>
)}
<Modal
open={previewOpen}
onClose={() => setPreviewOpen(false)}
title={description || t('agentStatus.resultTitle')}
width={900}
>
<div className="max-h-[70vh] overflow-y-auto">
<MarkdownRenderer content={fullOutputText || errorText} />
</div>
</Modal>
</div>
)
}
function ToolCallTree({
toolCall,
resultMap,
childToolCallsByParent,
compact = false,
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
compact?: boolean
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
return (
<div className={compact ? 'space-y-1' : ''}>
<ToolCallBlock
toolName={toolCall.toolName}
input={toolCall.input}
result={result ? { content: result.content, isError: result.isError } : null}
compact={compact}
/>
{childToolCalls.length > 0 && (
<div className={compact ? 'ml-4 border-l border-[var(--color-border)]/60 pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)]/60 pl-3'}>
<div className="space-y-1">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
/>
))}
</div>
</div>
)}
</div>
)
}
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, ToolResult>,
): string {
const input = toolCall.input && typeof toolCall.input === 'object'
? toolCall.input as Record<string, unknown>
: {}
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<string, unknown>).status === 'completed' &&
Array.isArray((content as Record<string, unknown>).content)
) {
return extractTextContent((content as Record<string, unknown>).content)
}
}
if (content && typeof content === 'object') {
return JSON.stringify(content)
}
return ''
}

View File

@ -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 (

View File

@ -33,10 +33,20 @@ export function Modal({ open, onClose, title, children, width = 560, footer }: M
<div
className="relative bg-[var(--color-surface)] rounded-[var(--radius-xl)] shadow-[var(--shadow-modal)] max-h-[85vh] flex flex-col"
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
role="dialog"
aria-modal="true"
>
{title && (
<div className="px-6 pt-6 pb-0">
<div className="flex items-start justify-between gap-4 px-6 pt-6 pb-0">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
)}

View File

@ -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',

View File

@ -230,6 +230,13 @@ export const zh: Record<TranslationKey, string> = {
'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} 个页面',

View File

@ -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', () => {

View File

@ -334,6 +334,7 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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,
})
}
}

View File

@ -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 }

View File

@ -18,6 +18,7 @@ export type MessageEntry = {
timestamp: string
model?: string
parentUuid?: string
parentToolUseId?: string
isSidechain?: boolean
}

View File

@ -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, [

View File

@ -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<Record<string, unknown>>) {
if (
block.type === 'tool_use' &&
block.name === 'Agent' &&
typeof block.id === 'string'
) {
return block.id
}
}
return undefined
}
private resolveParentToolUseId(
entry: RawEntry,
entriesByUuid: Map<string, RawEntry>,
cache: Map<string, string | undefined>,
): 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<string>()
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<string, RawEntry>()
const parentToolUseIdCache = new Map<string, string | undefined>()
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)
}

View File

@ -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 }

View File

@ -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,
}]
}
}