mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +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') {
|
if (subtype === 'session_state_changed') {
|
||||||
return [{
|
return [{
|
||||||
type: 'system_notification',
|
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 type { AppState } from '../../state/AppState.js'
|
||||||
import { IDLE_SPECULATION_STATE } from '../../state/AppStateStore.js'
|
import { IDLE_SPECULATION_STATE } from '../../state/AppStateStore.js'
|
||||||
import { createTaskStateBase } from '../../Task.js'
|
import { createTaskStateBase } from '../../Task.js'
|
||||||
@ -10,8 +11,9 @@ import {
|
|||||||
getCommandQueue,
|
getCommandQueue,
|
||||||
resetCommandQueue,
|
resetCommandQueue,
|
||||||
} from '../../utils/messageQueueManager.js'
|
} from '../../utils/messageQueueManager.js'
|
||||||
import { createAssistantMessage } from '../../utils/messages.js'
|
import { createAssistantMessage, createUserMessage } from '../../utils/messages.js'
|
||||||
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
|
import { extractAgentToolActivities, runAsyncAgentLifecycle } from './agentToolUtils.js'
|
||||||
|
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../SyntheticOutputTool/SyntheticOutputTool.js'
|
||||||
|
|
||||||
describe('runAsyncAgentLifecycle', () => {
|
describe('runAsyncAgentLifecycle', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -92,4 +94,151 @@ describe('runAsyncAgentLifecycle', () => {
|
|||||||
)
|
)
|
||||||
expect(String(getCommandQueue()[0]?.value)).toContain('Review complete.')
|
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,
|
classifyYoloAction,
|
||||||
} from '../../utils/permissions/yoloClassifier.js'
|
} from '../../utils/permissions/yoloClassifier.js'
|
||||||
import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.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 { isInProcessTeammate } from '../../utils/teammateContext.js'
|
||||||
import { getTokenCountFromUsage } from '../../utils/tokens.js'
|
import { getTokenCountFromUsage } from '../../utils/tokens.js'
|
||||||
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.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
|
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(
|
export function emitTaskProgress(
|
||||||
tracker: ProgressTracker,
|
tracker: ProgressTracker,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@ -579,6 +623,19 @@ export async function runAsyncAgentLifecycle({
|
|||||||
getProgressUpdate(tracker),
|
getProgressUpdate(tracker),
|
||||||
rootSetAppState,
|
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)
|
const lastToolName = getLastToolUseName(message)
|
||||||
if (lastToolName) {
|
if (lastToolName) {
|
||||||
emitTaskProgress(
|
emitTaskProgress(
|
||||||
|
|||||||
@ -66,11 +66,45 @@ type SessionStateChangedEvent = {
|
|||||||
state: 'idle' | 'running' | 'requires_action'
|
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 =
|
export type SdkEvent =
|
||||||
| TaskStartedEvent
|
| TaskStartedEvent
|
||||||
| TaskProgressEvent
|
| TaskProgressEvent
|
||||||
| TaskNotificationSdkEvent
|
| TaskNotificationSdkEvent
|
||||||
| SessionStateChangedEvent
|
| SessionStateChangedEvent
|
||||||
|
| AgentToolActivityEvent
|
||||||
|
|
||||||
const MAX_QUEUE_SIZE = 1000
|
const MAX_QUEUE_SIZE = 1000
|
||||||
const queue: SdkEvent[] = []
|
const queue: SdkEvent[] = []
|
||||||
@ -133,3 +167,25 @@ export function emitTaskTerminatedSdk(
|
|||||||
usage: opts?.usage,
|
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