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'])
})
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)\n<usage>total_tokens: 17195\ntool_uses: 2\nduration_ms: 41368</usage>",
},
],
},
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()
})

View File

@ -53,24 +53,16 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>()
let pendingToolCalls: ToolCall[] = []
const inlineParentToolUseIds = new Set<string>()
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 })
}
}

View File

@ -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({
))}
</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 className="text-[11px] text-[var(--color-text-tertiary)]">
{t('agentStatus.noActivity')}
</div>
) : (
<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
}
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 {
const text = extractTextContent(content).trim()
if (!text) return false

View File

@ -46,6 +46,21 @@ async function writeSessionFile(
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(
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)\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 () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [

View File

@ -132,6 +132,8 @@ type RawEntry = {
[key: string]: unknown
}
type ContentBlock = Record<string, unknown>
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<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(
entry: RawEntry,
entriesByUuid: Map<string, RawEntry>,
@ -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),
)
}
/**