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), + ) } /**