mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Keep subagent progress readable in desktop sessions
Desktop sessions can receive task tools, background-agent lifecycle events, and child tool stream events in the same turn. The UI now keeps root Agent cards separate from task-management tools, preserves parent links when later stream events omit them, and surfaces completed background-agent result text from task notifications. Constraint: Existing desktop session history can contain task-notification XML and live WS events with incomplete parent metadata. Rejected: Render local_agent lifecycle messages as standalone cards | duplicates the Agent tool card and makes ownership unclear. Confidence: high Scope-risk: moderate Directive: Do not collapse Agent and TaskCreate/TaskUpdate root tools into one generic group without checking multi-agent screenshots. Tested: SKIP_INSTALL=1 PRESERVE_TAURI_TARGET=1 ./desktop/scripts/build-macos-arm64.sh Tested: Real gpt-5.5 desktop E2E sessions under /tmp/cc-haha-subagent-e2e-20260519-025454 for 3 subagents, child tools, result dialog, and background Bash completion. Not-tested: Full bun run verify gate.
This commit is contained in:
parent
f2e3a31021
commit
2323e6ec47
@ -771,6 +771,68 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(toolGroups.map((item) => item.toolCalls[0]?.toolUseId)).toEqual(['agent-1', 'write-1'])
|
||||
})
|
||||
|
||||
it('keeps task-management tools from downgrading dispatched agents into a mixed tool tree', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: 'tool-task-create',
|
||||
type: 'tool_use',
|
||||
toolName: 'TaskCreate',
|
||||
toolUseId: 'task-create-1',
|
||||
input: { subject: 'Review recent changes' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'tool-task-update',
|
||||
type: 'tool_use',
|
||||
toolName: 'TaskUpdate',
|
||||
toolUseId: 'task-update-1',
|
||||
input: { id: '1', status: 'in_progress' },
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'tool-agent-a',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-a',
|
||||
input: { description: 'Review desktop impact' },
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'tool-agent-b',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-b',
|
||||
input: { description: 'Review runtime impact' },
|
||||
timestamp: 4,
|
||||
},
|
||||
{
|
||||
id: 'tool-agent-child-bash',
|
||||
type: 'tool_use',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'agent-a-bash',
|
||||
input: { command: 'git status --short' },
|
||||
timestamp: 5,
|
||||
parentToolUseId: 'agent-a',
|
||||
},
|
||||
]
|
||||
|
||||
const { renderItems, childToolCallsByParent } = buildRenderModel(messages)
|
||||
const toolGroups = renderItems.filter((item) => item.kind === 'tool_group')
|
||||
|
||||
expect(toolGroups).toHaveLength(2)
|
||||
expect(toolGroups[0]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'TaskCreate',
|
||||
'TaskUpdate',
|
||||
])
|
||||
expect(toolGroups[1]?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'Agent',
|
||||
'Agent',
|
||||
])
|
||||
expect(childToolCallsByParent.get('agent-a')?.map((toolCall) => toolCall.toolUseId)).toEqual([
|
||||
'agent-a-bash',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps later nested tool calls under their parent after an interleaved user message', () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
@ -967,6 +1029,52 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(screen.queryByRole('button', { name: 'View result' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows completed background agent result from the terminal task notification', () => {
|
||||
const resultText = '后台 agent 已经完成:定位到 parentToolUseId 丢失并补齐了 live 事件链。'
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'tool-agent',
|
||||
type: 'tool_use',
|
||||
toolName: 'Agent',
|
||||
toolUseId: 'agent-1',
|
||||
input: { description: '排查 subagent UI' },
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'result-agent',
|
||||
type: 'tool_result',
|
||||
toolUseId: 'agent-1',
|
||||
content:
|
||||
"Async agent launched successfully.\nagentId: a29934b04b20ed564 (internal ID - do not mention to user. Use SendMessage with to: 'a29934b04b20ed564' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.",
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
agentTaskNotifications: {
|
||||
'agent-1': {
|
||||
taskId: 'agent-task-1',
|
||||
toolUseId: 'agent-1',
|
||||
status: 'completed',
|
||||
summary: 'Agent "排查 subagent UI" completed',
|
||||
result: resultText,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(screen.getByText('Done')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View result' }))
|
||||
|
||||
expect(within(screen.getByRole('dialog')).getByText(resultText)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders copy controls for user messages and scopes assistant copy to a single reply', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, {
|
||||
|
||||
@ -383,6 +383,15 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
pendingToolCalls = []
|
||||
}
|
||||
}
|
||||
const appendRootToolCall = (toolCall: ToolCall) => {
|
||||
const nextIsAgent = toolCall.toolName === 'Agent'
|
||||
const pendingIsAgentGroup = pendingToolCalls.every((pendingToolCall) => pendingToolCall.toolName === 'Agent')
|
||||
|
||||
if (pendingToolCalls.length > 0 && pendingIsAgentGroup !== nextIsAgent) {
|
||||
flushGroup()
|
||||
}
|
||||
pendingToolCalls.push(toolCall)
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool_use') {
|
||||
@ -418,7 +427,7 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
flushGroup()
|
||||
items.push({ kind: 'message', message: msg })
|
||||
} else {
|
||||
pendingToolCalls.push(msg)
|
||||
appendRootToolCall(msg)
|
||||
}
|
||||
} else {
|
||||
flushGroup()
|
||||
|
||||
@ -506,6 +506,7 @@ function AgentCallCard({
|
||||
const statusClassName = getAgentStatusClassName(status)
|
||||
const statusLabel = getAgentStatusLabel(status, t)
|
||||
const taskSummary = agentTaskNotification?.summary?.trim() || ''
|
||||
const taskResult = agentTaskNotification?.result?.trim() || ''
|
||||
const errorText =
|
||||
status === 'failed'
|
||||
? taskSummary || (result?.isError ? getAgentErrorSummary(result.content) : '')
|
||||
@ -516,7 +517,7 @@ function AgentCallCard({
|
||||
result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content)
|
||||
? extractAgentDisplayText(result.content).trim()
|
||||
: ''
|
||||
const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '')
|
||||
const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskResult || taskSummary : '')
|
||||
const outputSummary = previewText ? getAgentOutputSummary(previewText) : ''
|
||||
const description = typeof input.description === 'string' ? input.description : ''
|
||||
|
||||
|
||||
@ -573,7 +573,7 @@ describe('chatStore history mapping', () => {
|
||||
id: 'task-notification',
|
||||
type: 'user',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command & agent done</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
|
||||
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command & agent done</summary>\n<result>Detailed result & next step</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
|
||||
},
|
||||
])
|
||||
|
||||
@ -583,6 +583,7 @@ describe('chatStore history mapping', () => {
|
||||
toolUseId: 'toolu_bg',
|
||||
status: 'completed',
|
||||
summary: 'Background command & agent done',
|
||||
result: 'Detailed result & next step',
|
||||
outputFile: 'C:\\Temp\\bg.output',
|
||||
},
|
||||
})
|
||||
@ -967,6 +968,49 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('retains live parent linkage when only content_start carries the parent id', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: makeSession(),
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'content_start',
|
||||
blockType: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
parentToolUseId: 'agent-1',
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'tool-1',
|
||||
input: { file_path: 'src/App.tsx' },
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'tool_use',
|
||||
toolUseId: 'tool-1',
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
toolUseId: 'tool-1',
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {
|
||||
const cliCommand = { name: 'builtin-help', description: 'Built-in command' }
|
||||
const projectCommand = { name: 'project-probe', description: 'Project custom command' }
|
||||
@ -1229,6 +1273,7 @@ describe('chatStore history mapping', () => {
|
||||
tool_use_id: 'agent-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Agent "修复异常处理" completed',
|
||||
result: '修复了异常处理并补充了回归覆盖。',
|
||||
output_file: '/tmp/agent-output.txt',
|
||||
},
|
||||
})
|
||||
@ -1242,6 +1287,7 @@ describe('chatStore history mapping', () => {
|
||||
toolUseId: 'agent-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Agent "修复异常处理" completed',
|
||||
result: '修复了异常处理并补充了回归覆盖。',
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
})
|
||||
})
|
||||
@ -1323,6 +1369,7 @@ describe('chatStore history mapping', () => {
|
||||
tool_use_id: 'agent-tool-1',
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
result: 'Root cause was a stale session cache entry.',
|
||||
output_file: '/tmp/agent-output.txt',
|
||||
usage: {
|
||||
total_tokens: 2400,
|
||||
@ -1345,6 +1392,7 @@ describe('chatStore history mapping', () => {
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications['agent-tool-1']).toMatchObject({
|
||||
status: 'completed',
|
||||
summary: 'Found and fixed localStorage corruption.',
|
||||
result: 'Root cause was a stale session cache entry.',
|
||||
outputFile: '/tmp/agent-output.txt',
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||||
|
||||
@ -144,6 +144,7 @@ type ChatStore = {
|
||||
|
||||
const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
|
||||
const pendingTaskToolUseIdsBySession = new Map<string, Set<string>>()
|
||||
const pendingToolParentUseIdsBySession = new Map<string, Map<string, string>>()
|
||||
|
||||
function addPendingTaskToolUseId(sessionId: string, toolUseId: string): void {
|
||||
const ids = pendingTaskToolUseIdsBySession.get(sessionId) ?? new Set<string>()
|
||||
@ -162,6 +163,34 @@ function consumePendingTaskToolUseId(sessionId: string, toolUseId: string): bool
|
||||
function clearPendingTaskToolUseIds(sessionId: string): void {
|
||||
pendingTaskToolUseIdsBySession.delete(sessionId)
|
||||
}
|
||||
|
||||
function rememberPendingToolParentUseId(
|
||||
sessionId: string,
|
||||
toolUseId: string | null | undefined,
|
||||
parentToolUseId: string | undefined,
|
||||
): void {
|
||||
if (!toolUseId || !parentToolUseId) return
|
||||
const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId) ?? new Map<string, string>()
|
||||
parentUseIds.set(toolUseId, parentToolUseId)
|
||||
pendingToolParentUseIdsBySession.set(sessionId, parentUseIds)
|
||||
}
|
||||
|
||||
function getPendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined {
|
||||
return pendingToolParentUseIdsBySession.get(sessionId)?.get(toolUseId)
|
||||
}
|
||||
|
||||
function consumePendingToolParentUseId(sessionId: string, toolUseId: string): string | undefined {
|
||||
const parentUseIds = pendingToolParentUseIdsBySession.get(sessionId)
|
||||
if (!parentUseIds) return undefined
|
||||
const parentToolUseId = parentUseIds.get(toolUseId)
|
||||
parentUseIds.delete(toolUseId)
|
||||
if (parentUseIds.size === 0) pendingToolParentUseIdsBySession.delete(sessionId)
|
||||
return parentToolUseId
|
||||
}
|
||||
|
||||
function clearPendingToolParentUseIds(sessionId: string): void {
|
||||
pendingToolParentUseIdsBySession.delete(sessionId)
|
||||
}
|
||||
const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160
|
||||
|
||||
let msgCounter = 0
|
||||
@ -473,6 +502,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
|
||||
}
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
wsManager.disconnect(sessionId)
|
||||
set((s) => {
|
||||
const { [sessionId]: _, ...rest } = s.sessions
|
||||
@ -790,6 +820,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
|
||||
clearMessages: (sessionId) => {
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) }))
|
||||
},
|
||||
|
||||
@ -856,6 +887,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
activeThinkingId: null,
|
||||
}))
|
||||
} else if (msg.blockType === 'tool_use') {
|
||||
rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId)
|
||||
update(() => ({
|
||||
activeToolUseId: msg.toolUseId ?? null,
|
||||
activeToolName: msg.toolName ?? null,
|
||||
@ -909,11 +941,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
case 'tool_use_complete': {
|
||||
const session = get().sessions[sessionId]
|
||||
const toolName = msg.toolName || session?.activeToolName || 'unknown'
|
||||
const toolUseId = msg.toolUseId || session?.activeToolUseId || ''
|
||||
const parentToolUseId = msg.parentToolUseId ?? getPendingToolParentUseId(sessionId, toolUseId)
|
||||
rememberPendingToolParentUseId(sessionId, toolUseId, parentToolUseId)
|
||||
update((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(), type: 'tool_use', toolName,
|
||||
toolUseId: msg.toolUseId || s.activeToolUseId || '',
|
||||
input: msg.input, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
|
||||
toolUseId,
|
||||
input: msg.input, timestamp: Date.now(), parentToolUseId,
|
||||
}],
|
||||
activeToolUseId: null, activeToolName: null, activeThinkingId: null, streamingToolInput: '',
|
||||
}))
|
||||
@ -926,11 +961,13 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
case 'tool_result': {
|
||||
const pendingParentToolUseId = consumePendingToolParentUseId(sessionId, msg.toolUseId)
|
||||
const parentToolUseId = msg.parentToolUseId ?? pendingParentToolUseId
|
||||
update((s) => ({
|
||||
messages: [...s.messages, {
|
||||
id: nextId(), type: 'tool_result', toolUseId: msg.toolUseId,
|
||||
content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId: msg.parentToolUseId,
|
||||
content: msg.content, isError: msg.isError, timestamp: Date.now(), parentToolUseId,
|
||||
}],
|
||||
chatState: 'thinking', activeThinkingId: null,
|
||||
}))
|
||||
@ -938,6 +975,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
useCLITaskStore.getState().refreshTasks(sessionId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'permission_request':
|
||||
notifyDesktop({
|
||||
@ -1122,6 +1160,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
}))
|
||||
clearPendingDelta(sessionId)
|
||||
clearPendingTaskToolUseIds(sessionId)
|
||||
clearPendingToolParentUseIds(sessionId)
|
||||
useCLITaskStore.getState().clearTasks(sessionId)
|
||||
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')
|
||||
useTabStore.getState().updateTabTitle(sessionId, 'New Session')
|
||||
@ -1203,6 +1242,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
typeof data.tool_use_id === 'string' && data.tool_use_id.trim()
|
||||
? data.tool_use_id
|
||||
: null
|
||||
const taskResult = readNonEmptyString(data, 'result')
|
||||
const taskStatus = data.status
|
||||
if (taskEvent) {
|
||||
const now = Date.now()
|
||||
@ -1228,6 +1268,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
toolUseId,
|
||||
status: taskStatus,
|
||||
summary: taskEvent.summary,
|
||||
result: taskResult,
|
||||
outputFile: taskEvent.outputFile,
|
||||
usage: taskEvent.usage,
|
||||
},
|
||||
@ -1547,12 +1588,14 @@ function extractTaskNotification(content: unknown): AgentTaskNotification | null
|
||||
|
||||
const taskId = readXmlTag(xml, 'task-id') || toolUseId
|
||||
const summary = readXmlTag(xml, 'summary')
|
||||
const result = readXmlTag(xml, 'result')
|
||||
const outputFile = readXmlTag(xml, 'output-file')
|
||||
return {
|
||||
taskId,
|
||||
toolUseId,
|
||||
status,
|
||||
...(summary ? { summary } : {}),
|
||||
...(result ? { result } : {}),
|
||||
...(outputFile ? { outputFile } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,6 +155,7 @@ export type AgentTaskNotification = {
|
||||
toolUseId: string
|
||||
status: 'completed' | 'failed' | 'stopped'
|
||||
summary?: string
|
||||
result?: string
|
||||
outputFile?: string
|
||||
usage?: BackgroundAgentTaskUsage
|
||||
timestamp?: string
|
||||
|
||||
@ -2032,6 +2032,9 @@ function runHeadlessStreaming(
|
||||
const summaryMatch = notificationText.match(
|
||||
/<summary>([^<]+)<\/summary>/,
|
||||
)
|
||||
const resultMatch = notificationText.match(
|
||||
/<result>([\s\S]*?)<\/result>/,
|
||||
)
|
||||
|
||||
const isValidStatus = (
|
||||
s: string | undefined,
|
||||
@ -2077,6 +2080,7 @@ function runHeadlessStreaming(
|
||||
status,
|
||||
output_file: outputFileMatch?.[1] ?? '',
|
||||
summary: summaryMatch?.[1] ?? '',
|
||||
result: resultMatch?.[1],
|
||||
usage:
|
||||
totalTokensMatch && toolUsesMatch
|
||||
? {
|
||||
|
||||
@ -1700,6 +1700,7 @@ export const SDKTaskNotificationMessageSchema = lazySchema(() =>
|
||||
status: z.enum(['completed', 'failed', 'stopped']),
|
||||
output_file: z.string(),
|
||||
summary: z.string(),
|
||||
result: z.string().optional(),
|
||||
usage: z
|
||||
.object({
|
||||
total_tokens: z.number(),
|
||||
|
||||
@ -1732,7 +1732,7 @@ describe('Sessions API', () => {
|
||||
makeUserEntry('Hello'),
|
||||
makeAssistantEntry('World'),
|
||||
makeUserEntry(
|
||||
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed & stopped</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
|
||||
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed & stopped</summary>\n<result>Stack trace & failed assertion</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
|
||||
crypto.randomUUID(),
|
||||
),
|
||||
makeAssistantEntry('internal task response'),
|
||||
@ -1753,6 +1753,7 @@ describe('Sessions API', () => {
|
||||
toolUseId: 'toolu_bg',
|
||||
status: 'failed',
|
||||
summary: 'Background command failed & stopped',
|
||||
result: 'Stack trace & failed assertion',
|
||||
outputFile: 'C:\\Temp\\bg.output',
|
||||
timestamp: expect.any(String),
|
||||
},
|
||||
|
||||
@ -253,6 +253,69 @@ describe('WebSocket goal command events', () => {
|
||||
})
|
||||
|
||||
describe('WebSocket stream event translation', () => {
|
||||
it('keeps subagent parent linkage when later stream events omit the parent id', () => {
|
||||
const sessionId = `subagent-parent-${crypto.randomUUID()}`
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'stream_event',
|
||||
parent_tool_use_id: 'agent-1',
|
||||
event: {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'tool_use', id: 'read-1', name: 'Read' },
|
||||
},
|
||||
}, sessionId)).toEqual([
|
||||
{
|
||||
type: 'content_start',
|
||||
blockType: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'stream_event',
|
||||
event: {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'input_json_delta', partial_json: '{"file_path":"src/App.tsx"}' },
|
||||
},
|
||||
}, sessionId)).toEqual([
|
||||
{ type: 'content_delta', toolInput: '{"file_path":"src/App.tsx"}' },
|
||||
])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'stream_event',
|
||||
event: { type: 'content_block_stop', index: 0 },
|
||||
}, sessionId)).toEqual([
|
||||
{
|
||||
type: 'tool_use_complete',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: 'src/App.tsx' },
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
])
|
||||
|
||||
expect(translateCliMessage({
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'read-1', content: 'ok' },
|
||||
],
|
||||
},
|
||||
}, sessionId)).toEqual([
|
||||
{
|
||||
type: 'tool_result',
|
||||
toolUseId: 'read-1',
|
||||
content: 'ok',
|
||||
isError: false,
|
||||
parentToolUseId: 'agent-1',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps DeepSeek-style thinking blocks in thinking state until text starts', () => {
|
||||
const sessionId = `deepseek-thinking-${crypto.randomUUID()}`
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@ export type SessionTaskNotification = {
|
||||
toolUseId: string
|
||||
status: 'completed' | 'failed' | 'stopped'
|
||||
summary?: string
|
||||
result?: string
|
||||
outputFile?: string
|
||||
timestamp?: string
|
||||
}
|
||||
@ -533,12 +534,14 @@ export class SessionService {
|
||||
|
||||
const taskId = this.readXmlTag(xml, 'task-id') || toolUseId
|
||||
const summary = this.readXmlTag(xml, 'summary')
|
||||
const result = this.readXmlTag(xml, 'result')
|
||||
const outputFile = this.readXmlTag(xml, 'output-file')
|
||||
return {
|
||||
taskId,
|
||||
toolUseId,
|
||||
status,
|
||||
...(summary ? { summary } : {}),
|
||||
...(result ? { result } : {}),
|
||||
...(outputFile ? { outputFile } : {}),
|
||||
...(timestamp ? { timestamp } : {}),
|
||||
}
|
||||
|
||||
@ -710,11 +710,12 @@ function triggerTitleGeneration(ws: ServerWebSocket<WebSocketData>, sessionId: s
|
||||
type SessionStreamState = {
|
||||
hasReceivedStreamEvents: boolean
|
||||
activeBlockTypes: Map<number, 'text' | 'tool_use' | 'thinking'>
|
||||
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string }>
|
||||
activeToolBlocks: Map<number, { toolName: string; toolUseId: string; inputJson: string; parentToolUseId?: string }>
|
||||
pendingLocalCommand?: { name: string; args: string }
|
||||
/** Tool blocks whose input JSON failed to parse in content_block_stop.
|
||||
* The assistant message carries the complete input — defer to that. */
|
||||
pendingToolBlocks: Map<string, { toolName: string; toolUseId: string; parentToolUseId?: string }>
|
||||
toolParentUseIds: Map<string, string>
|
||||
lastApiError?: {
|
||||
message: string
|
||||
code: string
|
||||
@ -732,6 +733,7 @@ function getStreamState(sessionId: string): SessionStreamState {
|
||||
activeToolBlocks: new Map(),
|
||||
pendingLocalCommand: undefined,
|
||||
pendingToolBlocks: new Map(),
|
||||
toolParentUseIds: new Map(),
|
||||
lastApiError: undefined,
|
||||
}
|
||||
sessionStreamStates.set(sessionId, state)
|
||||
@ -739,6 +741,31 @@ function getStreamState(sessionId: string): SessionStreamState {
|
||||
return state
|
||||
}
|
||||
|
||||
function cliParentToolUseId(cliMsg: any): string | undefined {
|
||||
return typeof cliMsg.parent_tool_use_id === 'string' && cliMsg.parent_tool_use_id.length > 0
|
||||
? cliMsg.parent_tool_use_id
|
||||
: undefined
|
||||
}
|
||||
|
||||
function rememberToolParentUseId(
|
||||
streamState: SessionStreamState,
|
||||
toolUseId: string | undefined,
|
||||
parentToolUseId: string | undefined,
|
||||
): void {
|
||||
if (!toolUseId || !parentToolUseId) return
|
||||
streamState.toolParentUseIds.set(toolUseId, parentToolUseId)
|
||||
}
|
||||
|
||||
function consumeToolParentUseId(
|
||||
streamState: SessionStreamState,
|
||||
toolUseId: string | undefined,
|
||||
): string | undefined {
|
||||
if (!toolUseId) return undefined
|
||||
const parentToolUseId = streamState.toolParentUseIds.get(toolUseId)
|
||||
streamState.toolParentUseIds.delete(toolUseId)
|
||||
return parentToolUseId
|
||||
}
|
||||
|
||||
/** Clean up stream state when session disconnects */
|
||||
function cleanupStreamState(sessionId: string) {
|
||||
sessionStreamStates.delete(sessionId)
|
||||
@ -928,6 +955,7 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
if (block.type === 'tool_use' && streamState.pendingToolBlocks.has(block.id)) {
|
||||
const pending = streamState.pendingToolBlocks.get(block.id)!
|
||||
streamState.pendingToolBlocks.delete(block.id)
|
||||
rememberToolParentUseId(streamState, block.id, pending.parentToolUseId)
|
||||
messages.push({
|
||||
type: 'tool_use_complete',
|
||||
toolName: pending.toolName || block.name,
|
||||
@ -944,15 +972,14 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
messages.push({ type: 'content_start', blockType: 'text' })
|
||||
messages.push({ type: 'content_delta', text: block.text })
|
||||
} else if (block.type === 'tool_use') {
|
||||
const parentToolUseId = cliParentToolUseId(cliMsg)
|
||||
rememberToolParentUseId(streamState, block.id, parentToolUseId)
|
||||
messages.push({
|
||||
type: 'tool_use_complete',
|
||||
toolName: block.name,
|
||||
toolUseId: block.id,
|
||||
input: block.input,
|
||||
parentToolUseId:
|
||||
typeof cliMsg.parent_tool_use_id === 'string'
|
||||
? cliMsg.parent_tool_use_id
|
||||
: undefined,
|
||||
parentToolUseId,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -996,15 +1023,15 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
|
||||
for (const block of cliMsg.message.content) {
|
||||
if (block.type === 'tool_result') {
|
||||
const rememberedParentToolUseId = consumeToolParentUseId(streamState, block.tool_use_id)
|
||||
const parentToolUseId =
|
||||
cliParentToolUseId(cliMsg) ?? rememberedParentToolUseId
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
toolUseId: block.tool_use_id,
|
||||
content: block.content,
|
||||
isError: !!block.is_error,
|
||||
parentToolUseId:
|
||||
typeof cliMsg.parent_tool_use_id === 'string'
|
||||
? cliMsg.parent_tool_use_id
|
||||
: undefined,
|
||||
parentToolUseId,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1030,22 +1057,21 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
const index = event.index ?? 0
|
||||
|
||||
if (contentBlock.type === 'tool_use') {
|
||||
const parentToolUseId = cliParentToolUseId(cliMsg)
|
||||
streamState.activeBlockTypes.set(index, 'tool_use')
|
||||
// Track tool info so content_block_stop can emit complete data
|
||||
streamState.activeToolBlocks.set(index, {
|
||||
toolName: contentBlock.name || '',
|
||||
toolUseId: contentBlock.id || '',
|
||||
inputJson: '',
|
||||
parentToolUseId,
|
||||
})
|
||||
return [{
|
||||
type: 'content_start',
|
||||
blockType: 'tool_use',
|
||||
toolName: contentBlock.name,
|
||||
toolUseId: contentBlock.id,
|
||||
parentToolUseId:
|
||||
typeof cliMsg.parent_tool_use_id === 'string'
|
||||
? cliMsg.parent_tool_use_id
|
||||
: undefined,
|
||||
parentToolUseId,
|
||||
}]
|
||||
}
|
||||
|
||||
@ -1088,13 +1114,12 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
streamState.activeToolBlocks.delete(index)
|
||||
if (toolBlock) {
|
||||
const parentToolUseId =
|
||||
typeof cliMsg.parent_tool_use_id === 'string'
|
||||
? cliMsg.parent_tool_use_id
|
||||
: undefined
|
||||
cliParentToolUseId(cliMsg) ?? toolBlock.parentToolUseId
|
||||
let parsedInput = null
|
||||
try { parsedInput = JSON.parse(toolBlock.inputJson) } catch {}
|
||||
|
||||
if (parsedInput !== null) {
|
||||
rememberToolParentUseId(streamState, toolBlock.toolUseId, parentToolUseId)
|
||||
return [{
|
||||
type: 'tool_use_complete',
|
||||
toolName: toolBlock.toolName,
|
||||
|
||||
@ -46,6 +46,7 @@ type TaskNotificationSdkEvent = {
|
||||
status: 'completed' | 'failed' | 'stopped'
|
||||
output_file: string
|
||||
summary: string
|
||||
result?: string
|
||||
usage?: {
|
||||
total_tokens: number
|
||||
tool_uses: number
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user