Merge branch 'claude/serene-ptolemy-36ca25': background subagent tool activity (#829)

This commit is contained in:
程序员阿江(Relakkes) 2026-06-16 16:02:00 +08:00
commit 92430ba001
5 changed files with 371 additions and 3 deletions

View File

@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test'
import { translateCliMessage } from '../ws/handler.js'
// A background (async) agent's tool activity arrives on the parent stdout
// stream as a system/agent_tool_activity SDK event. translateCliMessage must
// re-emit it as a normal tool_use_complete / tool_result carrying the parent
// Agent tool_use_id, so the desktop groups it under the agent card exactly
// like a synchronous subagent (childToolCallsByParent). Regression guard for
// the "background subagents stuck on 'no tool activity'" bug.
describe('translateCliMessage: agent_tool_activity', () => {
it('re-emits a background tool_use as tool_use_complete with the parent id', () => {
const out = translateCliMessage(
{
type: 'system',
subtype: 'agent_tool_activity',
task_id: 'agent-1',
tool_use_id: 'toolu_parent',
activity: {
kind: 'tool_use',
tool_name: 'Bash',
tool_use_id: 'toolu_child',
input: { command: 'ls' },
},
},
'session-1',
)
expect(out).toEqual([
{
type: 'tool_use_complete',
toolName: 'Bash',
toolUseId: 'toolu_child',
input: { command: 'ls' },
parentToolUseId: 'toolu_parent',
},
])
})
it('re-emits a background tool_result with the parent id', () => {
const out = translateCliMessage(
{
type: 'system',
subtype: 'agent_tool_activity',
task_id: 'agent-1',
tool_use_id: 'toolu_parent',
activity: {
kind: 'tool_result',
tool_use_id: 'toolu_child',
content: 'done',
is_error: true,
},
},
'session-1',
)
expect(out).toEqual([
{
type: 'tool_result',
toolUseId: 'toolu_child',
content: 'done',
isError: true,
parentToolUseId: 'toolu_parent',
},
])
})
it('returns nothing for an unknown activity kind', () => {
const out = translateCliMessage(
{
type: 'system',
subtype: 'agent_tool_activity',
task_id: 'agent-1',
tool_use_id: 'toolu_parent',
activity: { kind: 'mystery' },
},
'session-1',
)
expect(out).toEqual([])
})
})

View File

@ -1825,6 +1825,34 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
},
]
}
if (subtype === 'agent_tool_activity') {
// Tool activity streamed from a background (async) agent. Re-emit as a
// normal tool_use_complete / tool_result carrying the parent Agent
// tool_use_id, so the desktop groups it under the agent card exactly
// like a synchronous subagent (childToolCallsByParent).
const activity = cliMsg.activity
const parentToolUseId =
typeof cliMsg.tool_use_id === 'string' ? cliMsg.tool_use_id : undefined
if (activity?.kind === 'tool_use') {
return [{
type: 'tool_use_complete',
toolName: activity.tool_name,
toolUseId: activity.tool_use_id,
input: activity.input,
parentToolUseId,
}]
}
if (activity?.kind === 'tool_result') {
return [{
type: 'tool_result',
toolUseId: activity.tool_use_id,
content: activity.content,
isError: activity.is_error === true,
parentToolUseId,
}]
}
return []
}
if (subtype === 'session_state_changed') {
return [{
type: 'system_notification',

View File

@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { afterEach, describe, expect, spyOn, test } from 'bun:test'
import * as sdkEventQueue from '../../utils/sdkEventQueue.js'
import type { AppState } from '../../state/AppState.js'
import { IDLE_SPECULATION_STATE } from '../../state/AppStateStore.js'
import { createTaskStateBase } from '../../Task.js'
@ -10,8 +11,9 @@ import {
getCommandQueue,
resetCommandQueue,
} from '../../utils/messageQueueManager.js'
import { createAssistantMessage } from '../../utils/messages.js'
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
import { createAssistantMessage, createUserMessage } from '../../utils/messages.js'
import { extractAgentToolActivities, runAsyncAgentLifecycle } from './agentToolUtils.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../SyntheticOutputTool/SyntheticOutputTool.js'
describe('runAsyncAgentLifecycle', () => {
afterEach(() => {
@ -92,4 +94,151 @@ describe('runAsyncAgentLifecycle', () => {
)
expect(String(getCommandQueue()[0]?.value)).toContain('Review complete.')
})
test('streams a background agent\'s tool activity tagged with the parent tool_use id', async () => {
const emitSpy = spyOn(sdkEventQueue, 'emitAgentToolActivity').mockImplementation(
() => {},
)
try {
const taskId = 'agent-activity'
const abortController = new AbortController()
const task: LocalAgentTaskState = {
...createTaskStateBase(taskId, 'local_agent', 'Probe', 'toolu_parent'),
status: 'running',
agentId: taskId,
prompt: 'Probe',
agentType: 'general-purpose',
abortController,
retrieved: false,
lastReportedToolCount: 0,
lastReportedTokenCount: 0,
isBackgrounded: true,
pendingMessages: [],
retain: false,
diskLoaded: false,
}
let appState = {
tasks: { [taskId]: task },
toolPermissionContext: getEmptyToolPermissionContext(),
speculation: IDLE_SPECULATION_STATE,
} as unknown as AppState
const setAppState = (updater: (prev: AppState) => AppState): void => {
appState = updater(appState)
}
const toolUseMsg = createAssistantMessage({
content: [
{ type: 'tool_use', id: 'toolu_child', name: 'Bash', input: { command: 'ls' } },
],
}) as Message
const toolResultMsg = createUserMessage({
content: [
{ type: 'tool_result', tool_use_id: 'toolu_child', content: 'files', is_error: false },
],
}) as Message
async function* makeStream(): AsyncGenerator<Message, void> {
yield toolUseMsg
yield toolResultMsg
}
await Promise.race([
runAsyncAgentLifecycle({
taskId,
abortController,
makeStream,
metadata: {
prompt: 'Probe',
resolvedAgentModel: 'test-model',
isBuiltInAgent: true,
startTime: Date.now(),
agentType: 'general-purpose',
isAsync: true,
},
description: 'Probe',
toolUseContext: {
options: { tools: [] },
toolUseId: 'toolu_parent',
getAppState: () => appState,
} as unknown as ToolUseContext,
rootSetAppState: setAppState,
agentIdForCleanup: taskId,
enableSummarization: false,
getWorktreeResult: () => new Promise(() => {}),
}).then(() => 'completed'),
new Promise(resolve => setTimeout(() => resolve('timed-out'), 50)),
])
expect(emitSpy.mock.calls).toContainEqual([
taskId,
'toolu_parent',
{ kind: 'tool_use', tool_name: 'Bash', tool_use_id: 'toolu_child', input: { command: 'ls' } },
])
expect(emitSpy.mock.calls).toContainEqual([
taskId,
'toolu_parent',
{ kind: 'tool_result', tool_use_id: 'toolu_child', content: 'files', is_error: false },
])
} finally {
emitSpy.mockRestore()
}
})
})
describe('extractAgentToolActivities', () => {
test('extracts tool_use blocks from an assistant message', () => {
const message = createAssistantMessage({
content: [
{ type: 'text', text: 'Running a command' },
{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } },
],
}) as Message
expect(extractAgentToolActivities(message)).toEqual([
{ kind: 'tool_use', tool_name: 'Bash', tool_use_id: 'toolu_1', input: { command: 'ls' } },
])
})
test('skips the internal StructuredOutput tool', () => {
const message = createAssistantMessage({
content: [
{ type: 'tool_use', id: 'toolu_1', name: SYNTHETIC_OUTPUT_TOOL_NAME, input: {} },
{ type: 'tool_use', id: 'toolu_2', name: 'Read', input: { file_path: '/a' } },
],
}) as Message
expect(extractAgentToolActivities(message)).toEqual([
{ kind: 'tool_use', tool_name: 'Read', tool_use_id: 'toolu_2', input: { file_path: '/a' } },
])
})
test('extracts tool_result blocks from a user message', () => {
const message = createUserMessage({
content: [
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'output', is_error: false },
],
}) as Message
expect(extractAgentToolActivities(message)).toEqual([
{ kind: 'tool_result', tool_use_id: 'toolu_1', content: 'output', is_error: false },
])
})
test('marks errored tool_result blocks', () => {
const message = createUserMessage({
content: [
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'boom', is_error: true },
],
}) as Message
expect(extractAgentToolActivities(message)).toEqual([
{ kind: 'tool_result', tool_use_id: 'toolu_1', content: 'boom', is_error: true },
])
})
test('returns empty for string-only user content', () => {
const message = createUserMessage({ content: 'just text' }) as Message
expect(extractAgentToolActivities(message)).toEqual([])
})
test('returns empty for an assistant message with no tool_use', () => {
const message = createAssistantMessage({
content: [{ type: 'text', text: 'no tools here' }],
}) as Message
expect(extractAgentToolActivities(message)).toEqual([])
})
})

View File

@ -54,6 +54,8 @@ import {
classifyYoloAction,
} from '../../utils/permissions/yoloClassifier.js'
import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js'
import { emitAgentToolActivity, type AgentToolActivity } from '../../utils/sdkEventQueue.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../SyntheticOutputTool/SyntheticOutputTool.js'
import { isInProcessTeammate } from '../../utils/teammateContext.js'
import { getTokenCountFromUsage } from '../../utils/tokens.js'
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
@ -366,6 +368,48 @@ export function getLastToolUseName(message: MessageType): string | undefined {
return block?.type === 'tool_use' ? block.name : undefined
}
/**
* Extract the tool_use / tool_result blocks from a background agent message so
* they can be streamed to the desktop as the agent's tool activity. Skips the
* internal StructuredOutput tool (matches updateProgressFromMessage) and
* non-array user content (string-only messages carry no tool_result).
*/
export function extractAgentToolActivities(
message: MessageType,
): AgentToolActivity[] {
if (message.type === 'assistant') {
const activities: AgentToolActivity[] = []
for (const block of message.message.content) {
if (block.type === 'tool_use' && block.name !== SYNTHETIC_OUTPUT_TOOL_NAME) {
activities.push({
kind: 'tool_use',
tool_name: block.name,
tool_use_id: block.id,
input: block.input,
})
}
}
return activities
}
if (message.type === 'user') {
const content = message.message.content
if (!Array.isArray(content)) return []
const activities: AgentToolActivity[] = []
for (const block of content) {
if (block.type === 'tool_result') {
activities.push({
kind: 'tool_result',
tool_use_id: block.tool_use_id,
content: block.content,
is_error: block.is_error === true,
})
}
}
return activities
}
return []
}
export function emitTaskProgress(
tracker: ProgressTracker,
taskId: string,
@ -579,6 +623,19 @@ export async function runAsyncAgentLifecycle({
getProgressUpdate(tracker),
rootSetAppState,
)
// Stream this background agent's tool activity to the desktop so its
// card shows tool_use/tool_result in real time (childToolCallsByParent),
// instead of being stuck on "no tool activity". Synchronous subagents
// surface activity through the normal in-loop progress path; background
// agents are detached (void runAsyncAgentLifecycle), so we route through
// the SDK event queue → stdout → ws handler, tagged with the parent
// Agent tool_use_id so the UI groups it under the right card.
const parentToolUseId = toolUseContext.toolUseId
if (parentToolUseId) {
for (const activity of extractAgentToolActivities(message)) {
emitAgentToolActivity(taskId, parentToolUseId, activity)
}
}
const lastToolName = getLastToolUseName(message)
if (lastToolName) {
emitTaskProgress(

View File

@ -66,11 +66,45 @@ type SessionStateChangedEvent = {
state: 'idle' | 'running' | 'requires_action'
}
// A single tool_use / tool_result produced by a background (async) agent.
export type AgentToolActivity =
| {
kind: 'tool_use'
tool_name: string
tool_use_id: string
input: unknown
}
| {
kind: 'tool_result'
tool_use_id: string
content: unknown
is_error: boolean
}
// Emitted per tool_use / tool_result produced by a BACKGROUND (async) agent.
// Background agents run detached from the main query loop (void
// runAsyncAgentLifecycle), so their tool activity never reaches the parent's
// stdout stream the way a synchronous subagent's progress messages do —
// which is why the desktop shows their cards stuck on "no tool activity".
// Draining these into the output stream lets the desktop handler re-emit them
// as tool_use_complete / tool_result carrying the parent Agent tool_use_id, so
// the UI groups them under the agent card (childToolCallsByParent) exactly
// like a synchronous subagent. Does NOT trigger the LLM loop.
type AgentToolActivityEvent = {
type: 'system'
subtype: 'agent_tool_activity'
task_id: string
// Parent Agent tool_use id — the card this activity belongs under.
tool_use_id: string
activity: AgentToolActivity
}
export type SdkEvent =
| TaskStartedEvent
| TaskProgressEvent
| TaskNotificationSdkEvent
| SessionStateChangedEvent
| AgentToolActivityEvent
const MAX_QUEUE_SIZE = 1000
const queue: SdkEvent[] = []
@ -133,3 +167,25 @@ export function emitTaskTerminatedSdk(
usage: opts?.usage,
})
}
/**
* Emit one tool_use / tool_result produced by a background (async) agent so
* the desktop can render it under the parent Agent card in real time.
*
* No-op in interactive (TUI) mode enqueueSdkEvent only queues in
* headless/streaming mode (the desktop's CLI subprocess), and synchronous
* subagents already surface their activity through the normal progress path.
*/
export function emitAgentToolActivity(
taskId: string,
parentToolUseId: string,
activity: AgentToolActivity,
): void {
enqueueSdkEvent({
type: 'system',
subtype: 'agent_tool_activity',
task_id: taskId,
tool_use_id: parentToolUseId,
activity,
})
}