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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 13:34:37 +08:00
parent 628c4e16f9
commit e0abea6c46
5 changed files with 331 additions and 33 deletions

View File

@ -129,7 +129,7 @@ describe('MessageList nested tool calls', () => {
expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1']) 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[] = [ const messages: UIMessage[] = [
{ {
id: 'tool-agent', id: 'tool-agent',
@ -175,11 +175,37 @@ describe('MessageList nested tool calls', () => {
expect(renderedKinds).toEqual([ expect(renderedKinds).toEqual([
'tool:agent-1', 'tool:agent-1',
'message:user-follow-up', 'message:user-follow-up',
'tool:write-1',
]) ])
expect( expect(
(childToolCallsByParent.get('agent-1') ?? []).map((toolCall) => toolCall.toolUseId), (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', () => { 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', toolUseId: 'agent-1',
content: { content: {
status: 'completed', 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)\n<usage>total_tokens: 17195\ntool_uses: 2\nduration_ms: 41368</usage>",
},
],
}, },
isError: false, isError: false,
timestamp: 2, timestamp: 2,
@ -254,6 +286,8 @@ describe('MessageList nested tool calls', () => {
const dialog = screen.getByRole('dialog') const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText(/第二段补充内容用于验证 dialog 展示的是完整结果而不是截断摘要。/)).toBeTruthy() 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() expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy()
}) })

View File

@ -53,24 +53,16 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
const childToolCallsByParent = new Map<string, ToolCall[]>() const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>() const toolUseIds = new Set<string>()
let pendingToolCalls: ToolCall[] = [] let pendingToolCalls: ToolCall[] = []
const inlineParentToolUseIds = new Set<string>()
const flushGroup = (resetInlineParents = false) => { const flushGroup = () => {
if (pendingToolCalls.length > 0) { if (pendingToolCalls.length > 0) {
items.push({ items.push({
kind: 'tool_group', kind: 'tool_group',
toolCalls: [...pendingToolCalls], toolCalls: [...pendingToolCalls],
id: `group-${pendingToolCalls[0]!.id}`, id: `group-${pendingToolCalls[0]!.id}`,
}) })
for (const toolCall of pendingToolCalls) {
inlineParentToolUseIds.add(toolCall.toolUseId)
}
pendingToolCalls = [] pendingToolCalls = []
} }
if (resetInlineParents) {
inlineParentToolUseIds.clear()
}
} }
for (const msg of messages) { for (const msg of messages) {
@ -86,26 +78,24 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) { if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
continue continue
} }
if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
continue
}
if (msg.type === 'tool_use') { if (msg.type === 'tool_use') {
const parentIsPending = msg.parentToolUseId if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
? pendingToolCalls.some((toolCall) => toolCall.toolUseId === msg.parentToolUseId)
: false
if (msg.parentToolUseId && (inlineParentToolUseIds.has(msg.parentToolUseId) || parentIsPending)) {
flushGroup() flushGroup()
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg) appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
inlineParentToolUseIds.add(msg.toolUseId)
continue continue
} }
if (msg.toolName === 'AskUserQuestion') { if (msg.toolName === 'AskUserQuestion') {
flushGroup(true) flushGroup()
items.push({ kind: 'message', message: msg }) items.push({ kind: 'message', message: msg })
} else { } else {
pendingToolCalls.push(msg) pendingToolCalls.push(msg)
} }
} else { } else {
flushGroup(true) flushGroup()
items.push({ kind: 'message', message: msg }) items.push({ kind: 'message', message: msg })
} }
} }

View File

@ -288,7 +288,7 @@ function AgentCallCard({
: '' : ''
const fullOutputText = const fullOutputText =
result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content) result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content)
? extractTextContent(result.content).trim() ? extractAgentDisplayText(result.content).trim()
: '' : ''
const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '') const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '')
const outputSummary = previewText ? getAgentOutputSummary(previewText) : '' const outputSummary = previewText ? getAgentOutputSummary(previewText) : ''
@ -377,15 +377,8 @@ function AgentCallCard({
))} ))}
</div> </div>
) : outputSummary ? ( ) : outputSummary ? (
<div className="rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-3 py-3"> <div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="line-clamp-3 text-[11px] leading-[1.55] text-[var(--color-text-secondary)]"> {t('agentStatus.noActivity')}
{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>
) : ( ) : (
<div className="text-[11px] text-[var(--color-text-tertiary)]"> <div className="text-[11px] text-[var(--color-text-tertiary)]">
@ -552,6 +545,19 @@ function getAgentOutputSummary(content: string): string {
return text.length > 220 ? `${text.slice(0, 220)}...` : text 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(/<usage>[\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 { function isAgentLaunchResult(content: unknown): boolean {
const text = extractTextContent(content).trim() const text = extractTextContent(content).trim()
if (!text) return false if (!text) return false

View File

@ -46,6 +46,21 @@ async function writeSessionFile(
return filePath return filePath
} }
async function writeSubagentTranscriptFile(
projectDir: string,
sessionId: string,
agentId: string,
entries: Record<string, unknown>[],
): Promise<string> {
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( async function writeSkill(
rootDir: string, rootDir: string,
skillName: string, skillName: string,
@ -289,6 +304,110 @@ describe('SessionService', () => {
expect(messages).toHaveLength(2) 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)\n<usage>total_tokens: 10\ntool_uses: 2\nduration_ms: 30</usage>`,
},
],
},
],
},
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 () => { it('should hide synthetic interruption, no-response, and command breadcrumb transcript entries', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [ await writeSessionFile('-tmp-project', sessionId, [

View File

@ -132,6 +132,8 @@ type RawEntry = {
[key: string]: unknown [key: string]: unknown
} }
type ContentBlock = Record<string, unknown>
const USER_INTERRUPTION_TEXTS = new Set([ const USER_INTERRUPTION_TEXTS = new Set([
'[Request interrupted by user]', '[Request interrupted by user]',
'[Request interrupted by user for tool use]', '[Request interrupted by user for tool use]',
@ -350,6 +352,145 @@ export class SessionService {
return undefined 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<string, string> {
const agentToolUseIds = new Set(
messages.flatMap((message) => this.extractAgentToolUseIdsFromMessage(message)),
)
const resultLinks = new Map<string, string>()
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<MessageEntry[]> {
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<MessageEntry[]> {
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( private resolveParentToolUseId(
entry: RawEntry, entry: RawEntry,
entriesByUuid: Map<string, RawEntry>, entriesByUuid: Map<string, RawEntry>,
@ -783,7 +924,11 @@ export class SessionService {
const stat = await fs.stat(filePath) const stat = await fs.stat(filePath)
const entries = await this.readJsonlFile(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 title = this.extractTitle(entries)
const workDir = this.resolveWorkDirFromEntries(entries, projectDir) const workDir = this.resolveWorkDirFromEntries(entries, projectDir)
const workDirExists = await this.pathExists(workDir) const workDirExists = await this.pathExists(workDir)
@ -819,7 +964,11 @@ export class SessionService {
} }
const entries = await this.readJsonlFile(found.filePath) const entries = await this.readJsonlFile(found.filePath)
return this.entriesToMessages(entries) return await this.appendSubagentToolMessages(
found.projectDir,
sessionId,
this.entriesToMessages(entries),
)
} }
/** /**