diff --git a/src/server/__tests__/translateCliMessage.agentToolActivity.test.ts b/src/server/__tests__/translateCliMessage.agentToolActivity.test.ts new file mode 100644 index 00000000..a8ce9c0a --- /dev/null +++ b/src/server/__tests__/translateCliMessage.agentToolActivity.test.ts @@ -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([]) + }) +}) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 1cd8ec21..0cebff92 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -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', diff --git a/src/tools/AgentTool/agentToolUtils.test.ts b/src/tools/AgentTool/agentToolUtils.test.ts index bd953ff0..34b65d50 100644 --- a/src/tools/AgentTool/agentToolUtils.test.ts +++ b/src/tools/AgentTool/agentToolUtils.test.ts @@ -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 { + 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([]) + }) }) diff --git a/src/tools/AgentTool/agentToolUtils.ts b/src/tools/AgentTool/agentToolUtils.ts index 3643c700..8663c23d 100644 --- a/src/tools/AgentTool/agentToolUtils.ts +++ b/src/tools/AgentTool/agentToolUtils.ts @@ -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( diff --git a/src/utils/sdkEventQueue.ts b/src/utils/sdkEventQueue.ts index abdb112f..c18d2d63 100644 --- a/src/utils/sdkEventQueue.ts +++ b/src/utils/sdkEventQueue.ts @@ -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, + }) +}