mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge branch 'claude/serene-ptolemy-36ca25': background subagent tool activity (#829)
This commit is contained in:
commit
92430ba001
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user