From e0abea6c46e9c97192a62160ba78fdf90b6258f0 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: Tue, 28 Apr 2026 13:34:37 +0800 Subject: [PATCH] fix: Keep subagent tool activity nested in desktop transcripts Subagent transcripts are stored in sidecar JSONL files, so the desktop history loader now links completed Agent results back to their sidecar tool activity and namespaces child tool ids before rendering. The chat renderer then keeps parented tool calls and orphaned child results out of the root session stream, while the Agent result preview strips runtime metadata from the user-facing answer. Constraint: Subagent tool activity may live under ~/.claude/projects/{project}/{session}/subagents instead of the main session JSONL Rejected: Rely only on adjacent message ordering | interleaved messages can split parent and child tool activity Rejected: Show final Agent output inside the expanded card | expanded cards should show process activity while the modal shows the final answer Confidence: high Scope-risk: moderate Directive: Do not remove child tool id namespacing without proving multiple subagents cannot emit colliding tool ids Tested: bun test src/server/__tests__/sessions.test.ts Tested: cd desktop && bun run test -- MessageList.test.tsx chatStore.test.ts Tested: cd desktop && bun run lint Tested: agent-browser E2E with two dispatched subagents in /tmp/cc-haha-agent-e2e-q4Xm2s Not-tested: Live streaming of sidecar subagent tool activity before the sidecar file is persisted --- .../src/components/chat/MessageList.test.tsx | 42 ++++- desktop/src/components/chat/MessageList.tsx | 24 +-- desktop/src/components/chat/ToolCallGroup.tsx | 26 +-- src/server/__tests__/sessions.test.ts | 119 ++++++++++++++ src/server/services/sessionService.ts | 153 +++++++++++++++++- 5 files changed, 331 insertions(+), 33 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 270e86aa..ff2ae162 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -129,7 +129,7 @@ describe('MessageList nested tool calls', () => { expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1']) }) - it('keeps later nested tool calls after an interleaved user message', () => { + it('keeps later nested tool calls under their parent after an interleaved user message', () => { const messages: UIMessage[] = [ { id: 'tool-agent', @@ -175,11 +175,37 @@ describe('MessageList nested tool calls', () => { expect(renderedKinds).toEqual([ 'tool:agent-1', 'message:user-follow-up', - 'tool:write-1', ]) expect( (childToolCallsByParent.get('agent-1') ?? []).map((toolCall) => toolCall.toolUseId), - ).toEqual(['read-1']) + ).toEqual(['read-1', 'write-1']) + }) + + it('does not render parented orphan tool results as root session messages', () => { + const messages: UIMessage[] = [ + { + id: 'tool-agent', + type: 'tool_use', + toolName: 'Agent', + toolUseId: 'agent-1', + input: { description: 'Inspect src/components' }, + timestamp: 1, + }, + { + id: 'result-child', + type: 'tool_result', + toolUseId: 'grep-1', + content: 'Found 22 files', + isError: false, + timestamp: 2, + parentToolUseId: 'agent-1', + }, + ] + + const { renderItems } = buildRenderModel(messages) + + expect(renderItems).toHaveLength(1) + expect(renderItems[0]).toMatchObject({ kind: 'tool_group' }) }) it('shows failed agent status and compact unavailable summary for Explore launch errors', () => { @@ -235,7 +261,13 @@ describe('MessageList nested tool calls', () => { toolUseId: 'agent-1', content: { status: 'completed', - content: [{ type: 'text', text: longResult }], + content: [ + { type: 'text', text: longResult }, + { + type: 'text', + text: "agentId: a0c0c732f61442dc1 (use SendMessage with to: 'a0c0c732f61442dc1' to continue this agent)\ntotal_tokens: 17195\ntool_uses: 2\nduration_ms: 41368", + }, + ], }, isError: false, timestamp: 2, @@ -254,6 +286,8 @@ describe('MessageList nested tool calls', () => { const dialog = screen.getByRole('dialog') expect(within(dialog).getByText(/第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。/)).toBeTruthy() + expect(within(dialog).queryByText(/agentId:/)).toBeNull() + expect(within(dialog).queryByText(/total_tokens/)).toBeNull() expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy() }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index c59e39c1..aa980645 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -53,24 +53,16 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { const childToolCallsByParent = new Map() const toolUseIds = new Set() let pendingToolCalls: ToolCall[] = [] - const inlineParentToolUseIds = new Set() - const flushGroup = (resetInlineParents = false) => { + const flushGroup = () => { if (pendingToolCalls.length > 0) { items.push({ kind: 'tool_group', toolCalls: [...pendingToolCalls], id: `group-${pendingToolCalls[0]!.id}`, }) - for (const toolCall of pendingToolCalls) { - inlineParentToolUseIds.add(toolCall.toolUseId) - } pendingToolCalls = [] } - - if (resetInlineParents) { - inlineParentToolUseIds.clear() - } } for (const msg of messages) { @@ -86,26 +78,24 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel { if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { continue } + if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { + continue + } if (msg.type === 'tool_use') { - const parentIsPending = msg.parentToolUseId - ? pendingToolCalls.some((toolCall) => toolCall.toolUseId === msg.parentToolUseId) - : false - - if (msg.parentToolUseId && (inlineParentToolUseIds.has(msg.parentToolUseId) || parentIsPending)) { + if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) { flushGroup() appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) - inlineParentToolUseIds.add(msg.toolUseId) continue } if (msg.toolName === 'AskUserQuestion') { - flushGroup(true) + flushGroup() items.push({ kind: 'message', message: msg }) } else { pendingToolCalls.push(msg) } } else { - flushGroup(true) + flushGroup() items.push({ kind: 'message', message: msg }) } } diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 4277d620..aa2d0b3f 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -288,7 +288,7 @@ function AgentCallCard({ : '' const fullOutputText = result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content) - ? extractTextContent(result.content).trim() + ? extractAgentDisplayText(result.content).trim() : '' const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '') const outputSummary = previewText ? getAgentOutputSummary(previewText) : '' @@ -377,15 +377,8 @@ function AgentCallCard({ ))} ) : outputSummary ? ( -
-
- {outputSummary} -
-
- - {t('agentStatus.viewResult')} - -
+
+ {t('agentStatus.noActivity')}
) : (
@@ -552,6 +545,19 @@ function getAgentOutputSummary(content: string): string { return text.length > 220 ? `${text.slice(0, 220)}...` : text } +function extractAgentDisplayText(content: unknown): string { + return stripAgentResultMetadata(extractTextContent(content)) +} + +function stripAgentResultMetadata(text: string): string { + return text + .replace(/^\s*agentId:.*(?:\r?\n)?/gm, '') + .replace(/[\s\S]*?<\/usage>/g, '') + .replace(/^\s*(?:total_tokens|tool_uses|duration_ms):\s*\d+\s*$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + function isAgentLaunchResult(content: unknown): boolean { const text = extractTextContent(content).trim() if (!text) return false diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 721df398..7738fcb1 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -46,6 +46,21 @@ async function writeSessionFile( return filePath } +async function writeSubagentTranscriptFile( + projectDir: string, + sessionId: string, + agentId: string, + entries: Record[], +): Promise { + const dir = path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents') + await fs.mkdir(dir, { recursive: true }) + const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}` + const filePath = path.join(dir, `${normalizedAgentId}.jsonl`) + const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' + await fs.writeFile(filePath, content, 'utf-8') + return filePath +} + async function writeSkill( rootDir: string, skillName: string, @@ -289,6 +304,110 @@ describe('SessionService', () => { expect(messages).toHaveLength(2) }) + it('should append subagent tool calls under their parent agent tool result', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const projectDir = '-tmp-project' + const agentId = 'abc123' + + await writeSessionFile(projectDir, sessionId, [ + makeSnapshotEntry(), + makeUserEntry('Dispatch an agent'), + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'Agent:0', + name: 'Agent', + input: { description: 'Inspect alpha' }, + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:02.000Z', + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'Agent:0', + content: [ + { + type: 'text', + text: `alpha summary\nagentId: ${agentId} (use SendMessage with to: '${agentId}' to continue this agent)\ntotal_tokens: 10\ntool_uses: 2\nduration_ms: 30`, + }, + ], + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:03.000Z', + }, + ]) + await writeSubagentTranscriptFile(projectDir, sessionId, agentId, [ + { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'Read:0', + name: 'Read', + input: { file_path: '/tmp/alpha.txt' }, + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:04.000Z', + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'Read:0', + content: 'alpha body', + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:05.000Z', + }, + ]) + + const messages = await service.getSessionMessages(sessionId) + const childToolUse = messages.find( + (message) => message.type === 'tool_use' && message.parentToolUseId === 'Agent:0', + ) + const childToolResult = messages.find( + (message) => message.type === 'tool_result' && message.parentToolUseId === 'Agent:0', + ) + + expect(childToolUse?.content).toEqual([ + { + type: 'tool_use', + id: 'Agent:0/abc123/Read:0', + name: 'Read', + input: { file_path: '/tmp/alpha.txt' }, + }, + ]) + expect(childToolResult?.content).toEqual([ + { + type: 'tool_result', + tool_use_id: 'Agent:0/abc123/Read:0', + content: 'alpha body', + }, + ]) + }) + it('should hide synthetic interruption, no-response, and command breadcrumb transcript 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 651daa97..1220a0af 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -132,6 +132,8 @@ type RawEntry = { [key: string]: unknown } +type ContentBlock = Record + const USER_INTERRUPTION_TEXTS = new Set([ '[Request interrupted by user]', '[Request interrupted by user for tool use]', @@ -350,6 +352,145 @@ export class SessionService { return undefined } + private extractAgentToolUseIdsFromMessage(message: MessageEntry): string[] { + if (message.type !== 'tool_use' || !Array.isArray(message.content)) { + return [] + } + + return (message.content as ContentBlock[]) + .filter((block) => block.type === 'tool_use' && block.name === 'Agent') + .flatMap((block) => (typeof block.id === 'string' ? [block.id] : [])) + } + + private extractTextFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + + return (content as ContentBlock[]) + .flatMap((block) => (typeof block.text === 'string' ? [block.text] : [])) + .join('\n') + } + + private extractAgentIdFromResultText(text: string): string | undefined { + const match = text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/) + return match?.[1] + } + + private extractAgentResultLinks(messages: MessageEntry[]): Map { + const agentToolUseIds = new Set( + messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)), + ) + const resultLinks = new Map() + + for (const message of messages) { + if (message.type !== 'tool_result' || !Array.isArray(message.content)) { + continue + } + + for (const block of message.content as ContentBlock[]) { + if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') { + continue + } + if (!agentToolUseIds.has(block.tool_use_id)) { + continue + } + + const agentId = this.extractAgentIdFromResultText( + this.extractTextFromContent(block.content), + ) + if (agentId) { + resultLinks.set(block.tool_use_id, agentId) + } + } + } + + return resultLinks + } + + private namespaceSubagentContentIds(content: unknown, namespace: string): unknown { + if (!Array.isArray(content)) return content + + return (content as ContentBlock[]).map((block) => { + if (!block || typeof block !== 'object') return block + if (block.type === 'tool_use' && typeof block.id === 'string') { + return { ...block, id: `${namespace}/${block.id}` } + } + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + return { ...block, tool_use_id: `${namespace}/${block.tool_use_id}` } + } + return block + }) + } + + private subagentTranscriptPath( + projectDir: string, + sessionId: string, + agentId: string, + ): string { + const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}` + return path.join( + this.getProjectsDir(), + projectDir, + sessionId, + 'subagents', + `${normalizedAgentId}.jsonl`, + ) + } + + private async loadSubagentToolMessages( + projectDir: string, + sessionId: string, + parentToolUseId: string, + agentId: string, + ): Promise { + const filePath = this.subagentTranscriptPath(projectDir, sessionId, agentId) + const entries = await this.readJsonlFile(filePath) + const namespace = `${parentToolUseId}/${agentId}` + const messages: MessageEntry[] = [] + + for (const entry of entries) { + if (!entry.message?.role || entry.isMeta) continue + if (this.shouldHideTranscriptEntry(entry)) continue + if (entry.type !== 'user' && entry.type !== 'assistant' && entry.type !== 'system') { + continue + } + + const message = this.entryToMessage( + { + ...entry, + message: { + ...entry.message, + content: this.namespaceSubagentContentIds(entry.message.content, namespace), + }, + }, + parentToolUseId, + ) + if (message && (message.type === 'tool_use' || message.type === 'tool_result')) { + messages.push(message) + } + } + + return messages + } + + private async appendSubagentToolMessages( + projectDir: string, + sessionId: string, + messages: MessageEntry[], + ): Promise { + const resultLinks = this.extractAgentResultLinks(messages) + if (resultLinks.size === 0) { + return messages + } + + const childMessages = await Promise.all( + [...resultLinks.entries()].map(([parentToolUseId, agentId]) => + this.loadSubagentToolMessages(projectDir, sessionId, parentToolUseId, agentId), + ), + ) + return [...messages, ...childMessages.flat()] + } + private resolveParentToolUseId( entry: RawEntry, entriesByUuid: Map, @@ -783,7 +924,11 @@ export class SessionService { const stat = await fs.stat(filePath) const entries = await this.readJsonlFile(filePath) - const messages = this.entriesToMessages(entries) + const messages = await this.appendSubagentToolMessages( + projectDir, + sessionId, + this.entriesToMessages(entries), + ) const title = this.extractTitle(entries) const workDir = this.resolveWorkDirFromEntries(entries, projectDir) const workDirExists = await this.pathExists(workDir) @@ -819,7 +964,11 @@ export class SessionService { } const entries = await this.readJsonlFile(found.filePath) - return this.entriesToMessages(entries) + return await this.appendSubagentToolMessages( + found.projectDir, + sessionId, + this.entriesToMessages(entries), + ) } /**