From 841b7cc035581e4ff74a01d3c17eb38b2fc02ad0 Mon Sep 17 00:00:00 2001 From: SmallSpider <568442079@qq.com> Date: Thu, 7 May 2026 10:46:16 +0800 Subject: [PATCH] Add Codex multi-agent timeline support (#588) * checkpoint codex multiagent UI state * fix codex multiagent event scoping * fix: stabilize codex subagent timeline * chore: remove codex subagent nesting prompt * test(web): stabilize tool result rendering tests * fix: collapse codex agent trace rows by default * fix(web): keep chat scrolled to bottom * fix(web): backfill agent-run-heavy message loads * fix(codex): fail stuck subagent spawns * fix(codex): preserve wrapped child event scope * fix(codex): surface agent tool completions --- cli/src/claude/utils/startHappyServer.ts | 22 +- cli/src/codex/codexRemoteLauncher.test.ts | 698 ++++++++- cli/src/codex/codexRemoteLauncher.ts | 1249 ++++++++++++++++- cli/src/codex/utils/appServerConfig.test.ts | 35 +- cli/src/codex/utils/appServerConfig.ts | 12 +- .../utils/appServerEventConverter.test.ts | 303 +++- .../codex/utils/appServerEventConverter.ts | 318 ++++- .../utils/appServerPermissionAdapter.test.ts | 77 + .../codex/utils/appServerPermissionAdapter.ts | 118 ++ cli/src/codex/utils/buildHapiMcpBridge.ts | 13 +- web/src/chat/normalize.test.ts | 85 ++ web/src/chat/normalizeAgent.ts | 69 +- web/src/chat/reducer.test.ts | 46 + web/src/chat/reducer.ts | 6 +- web/src/chat/reducerTimeline.test.ts | 765 ++++++++++ web/src/chat/reducerTimeline.ts | 538 ++++++- web/src/chat/types.ts | 2 + .../components/AssistantChat/HappyThread.tsx | 11 +- .../messages/AssistantMessage.tsx | 20 + .../AssistantChat/messages/ToolMessage.tsx | 6 +- web/src/components/ToolCard/ToolCard.tsx | 18 +- web/src/components/ToolCard/codexAgents.ts | 336 +++++ .../components/ToolCard/knownTools.test.tsx | 129 ++ web/src/components/ToolCard/knownTools.tsx | 84 ++ web/src/components/ToolCard/trace.test.tsx | 45 + web/src/components/ToolCard/trace.tsx | 146 +- web/src/components/ToolCard/views/_all.tsx | 55 + .../ToolCard/views/_results.test.tsx | 148 ++ .../components/ToolCard/views/_results.tsx | 134 ++ web/src/lib/message-window-store.test.ts | 171 ++- web/src/lib/message-window-store.ts | 238 +++- 31 files changed, 5745 insertions(+), 152 deletions(-) create mode 100644 web/src/chat/reducer.test.ts create mode 100644 web/src/components/ToolCard/codexAgents.ts diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 7383f54b..dac93576 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -12,17 +12,25 @@ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -export async function startHappyServer(client: ApiSessionClient) { +type StartHappyServerOptions = { + emitTitleSummary?: boolean; +}; + +export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { + const emitTitleSummary = options.emitTitleSummary ?? true; + // Handler that sends title updates via the client const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { - // Send title as a summary message, similar to title generator - client.sendClaudeSessionMessage({ - type: 'summary', - summary: title, - leafUuid: randomUUID() - }); + if (emitTitleSummary) { + // Send title as a summary message, similar to title generator. + client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }); + } return { success: true }; } catch (error) { diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 290a9990..0176901a 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -17,7 +17,18 @@ const harness = vi.hoisted(() => ({ failResumeThreadIds: [] as string[], nextThreadSystemErrorMessage: null as string | null, failNextCompact: false, - deferThreadStatusNotifications: false + deferThreadStatusNotifications: false, + emitChildThreadEvents: false, + emitChildUsageEvents: false, + emitChildReasoningBurst: false, + emitParentUsageEvents: false, + emitChildNestedAgentTool: false, + emitParentTitleChange: false, + emitParentSpawnFailureWithoutAgentId: false, + emitParentSpawnStartWithoutEnd: false, + emitParentSendInputFailure: false, + emitParentResumeSuccess: false, + bridgeOptions: [] as unknown[] })); vi.mock('./codexAppServerClient', () => { @@ -97,6 +108,40 @@ vi.mock('./codexAppServerClient', () => { } if (params?.threadId === 'thread-1') { + if (harness.emitParentTitleChange) { + const titleStart = { + item: { + id: 'title-parent', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'Parent Title' } + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/started', params: titleStart }); + this.notificationHandler?.('item/started', titleStart); + + const titleEnd = { + item: { + id: 'title-parent', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + result: { + content: [ + { type: 'text', text: 'Successfully changed chat title to: "Parent Title"' } + ] + } + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/completed', params: titleEnd }); + this.notificationHandler?.('item/completed', titleEnd); + } + const commandStart = { item: { id: 'cmd-1', @@ -120,6 +165,330 @@ vi.mock('./codexAppServerClient', () => { }; harness.notifications.push({ method: 'item/completed', params: commandEnd }); this.notificationHandler?.('item/completed', commandEnd); + + if (harness.emitParentUsageEvents) { + const parentUsage = { + tokenUsage: { + thread_id: threadId, + turn_id: turnId, + last_token_usage: { + input_tokens: 100, + output_tokens: 10 + }, + model_context_window: 200_000 + } + }; + harness.notifications.push({ method: 'thread/tokenUsage/updated', params: parentUsage }); + this.notificationHandler?.('thread/tokenUsage/updated', parentUsage); + + const parentCompact = { thread: { id: threadId } }; + harness.notifications.push({ method: 'thread/compacted', params: parentCompact }); + this.notificationHandler?.('thread/compacted', parentCompact); + } + + if (harness.emitParentSpawnFailureWithoutAgentId || harness.emitParentSpawnStartWithoutEnd) { + const spawnStart = { + item: { + id: 'failed-spawn', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + prompt: 'do side work', + reasoningEffort: 'medium', + senderThreadId: threadId, + receiverThreadIds: [] + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/started', params: spawnStart }); + this.notificationHandler?.('item/started', spawnStart); + + if (harness.emitParentSpawnFailureWithoutAgentId) { + const spawnCompleted = { + item: { + id: 'failed-spawn', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'failed', + error: 'invalid spawn arguments', + senderThreadId: threadId, + receiverThreadIds: [], + agentsStates: {} + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/completed', params: spawnCompleted }); + this.notificationHandler?.('item/completed', spawnCompleted); + } + } + } + + if (harness.emitChildThreadEvents) { + const childThreadId = 'child-thread'; + const childTurnId = 'child-turn'; + const childMessage = 'child output should stay hidden'; + + if (harness.emitChildReasoningBurst) { + for (let i = 0; i < 20; i += 1) { + const reasoningDelta = { + msg: { + type: 'reasoning_content_delta', + item_id: 'child-reasoning', + delta: `step-${i} `, + thread_id: childThreadId, + turn_id: childTurnId + } + }; + harness.notifications.push({ method: 'codex/event/reasoning_content_delta', params: reasoningDelta }); + this.notificationHandler?.('codex/event/reasoning_content_delta', reasoningDelta); + } + } + + const childMessageCompleted = { + item: { + id: 'child-msg-1', + type: 'agentMessage', + content: [{ type: 'text', text: childMessage }] + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/completed', params: childMessageCompleted }); + this.notificationHandler?.('item/completed', childMessageCompleted); + + if (harness.emitChildUsageEvents) { + const childUsage = { + tokenUsage: { + thread_id: childThreadId, + turn_id: childTurnId, + last_token_usage: { + input_tokens: 30, + output_tokens: 3 + }, + model_context_window: 200_000 + } + }; + harness.notifications.push({ method: 'thread/tokenUsage/updated', params: childUsage }); + this.notificationHandler?.('thread/tokenUsage/updated', childUsage); + + const childCompact = { + msg: { + type: 'context_compacted', + thread_id: childThreadId, + turn_id: childTurnId + } + }; + harness.notifications.push({ method: 'codex/event/context_compacted', params: childCompact }); + this.notificationHandler?.('codex/event/context_compacted', childCompact); + + const ambiguousUsage = { + tokenUsage: { + last_token_usage: { + input_tokens: 999, + output_tokens: 1 + } + } + }; + harness.notifications.push({ method: 'thread/tokenUsage/updated', params: ambiguousUsage }); + this.notificationHandler?.('thread/tokenUsage/updated', ambiguousUsage); + } + + const childCommandStart = { + item: { + id: 'child-cmd-1', + type: 'commandExecution', + command: 'echo child' + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/started', params: childCommandStart }); + this.notificationHandler?.('item/started', childCommandStart); + this.notificationHandler?.('item/commandExecution/outputDelta', { + itemId: 'child-cmd-1', + delta: 'child stdout\n', + threadId: childThreadId, + turnId: childTurnId + }); + const childCommandEnd = { + item: { + id: 'child-cmd-1', + type: 'commandExecution', + exitCode: 0 + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/completed', params: childCommandEnd }); + this.notificationHandler?.('item/completed', childCommandEnd); + + const childTitleStart = { + item: { + id: 'title-child', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'Child Title' } + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/started', params: childTitleStart }); + this.notificationHandler?.('item/started', childTitleStart); + + const childTitleEnd = { + item: { + id: 'title-child', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + result: { + content: [ + { type: 'text', text: 'Successfully changed chat title to: "Child Title"' } + ] + } + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/completed', params: childTitleEnd }); + this.notificationHandler?.('item/completed', childTitleEnd); + + if (harness.emitChildNestedAgentTool) { + const nestedSpawnStart = { + item: { + id: 'nested-spawn', + type: 'collabAgentToolCall', + tool: 'spawn', + senderThreadId: childThreadId, + receiverThreadIds: ['grandchild-thread'], + prompt: 'do nested work' + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/started', params: nestedSpawnStart }); + this.notificationHandler?.('item/started', nestedSpawnStart); + + const nestedSpawnCompleted = { + item: { + id: 'nested-spawn', + type: 'collabAgentToolCall', + tool: 'spawn', + status: 'completed', + senderThreadId: childThreadId, + receiverThreadIds: ['grandchild-thread'], + agentsStates: {} + }, + threadId: childThreadId, + turnId: childTurnId + }; + harness.notifications.push({ method: 'item/completed', params: nestedSpawnCompleted }); + this.notificationHandler?.('item/completed', nestedSpawnCompleted); + } + + const waitStarted = { + item: { + id: 'wait-child', + type: 'collabAgentToolCall', + tool: 'wait', + senderThreadId: threadId, + receiverThreadIds: [childThreadId], + agentsStates: {} + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/started', params: waitStarted }); + this.notificationHandler?.('item/started', waitStarted); + + const waitCompleted = { + item: { + id: 'wait-child', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'completed', + senderThreadId: threadId, + receiverThreadIds: [childThreadId], + agentsStates: { + [childThreadId]: { + status: 'completed', + message: childMessage + } + } + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/completed', params: waitCompleted }); + this.notificationHandler?.('item/completed', waitCompleted); + + if (harness.emitParentSendInputFailure) { + const sendInputStarted = { + item: { + id: 'send-child', + type: 'collabAgentToolCall', + tool: 'sendInput', + senderThreadId: threadId, + receiverThreadIds: [childThreadId], + message: 'follow up' + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/started', params: sendInputStarted }); + this.notificationHandler?.('item/started', sendInputStarted); + + const sendInputCompleted = { + item: { + id: 'send-child', + type: 'collabAgentToolCall', + tool: 'sendInput', + status: 'failed', + error: 'send failed', + senderThreadId: threadId, + receiverThreadIds: [childThreadId], + agentsStates: {} + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/completed', params: sendInputCompleted }); + this.notificationHandler?.('item/completed', sendInputCompleted); + } + + if (harness.emitParentResumeSuccess) { + const resumeStarted = { + item: { + id: 'resume-child', + type: 'collabAgentToolCall', + tool: 'resumeAgent', + senderThreadId: threadId, + receiverThreadIds: [childThreadId] + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/started', params: resumeStarted }); + this.notificationHandler?.('item/started', resumeStarted); + + const resumeCompleted = { + item: { + id: 'resume-child', + type: 'collabAgentToolCall', + tool: 'resumeAgent', + status: 'completed', + senderThreadId: threadId, + receiverThreadIds: [childThreadId], + agentsStates: {} + }, + threadId, + turnId + }; + harness.notifications.push({ method: 'item/completed', params: resumeCompleted }); + this.notificationHandler?.('item/completed', resumeCompleted); + } } const completed = { status: 'Completed', turn: { id: turnId } }; @@ -144,12 +513,15 @@ vi.mock('./codexAppServerClient', () => { }); vi.mock('./utils/buildHapiMcpBridge', () => ({ - buildHapiMcpBridge: async () => ({ + buildHapiMcpBridge: async (_client: unknown, options?: unknown) => { + harness.bridgeOptions.push(options); + return { server: { stop: () => {} }, mcpServers: {} - }) + }; + } })); import { codexRemoteLauncher } from './codexRemoteLauncher'; @@ -179,6 +551,7 @@ function createSessionStub(messages = ['hello from launcher test']) { const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; + const summaryMessages: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; @@ -202,6 +575,9 @@ function createSessionStub(messages = ['hello from launcher test']) { codexMessages.push(message); }, sendUserMessage(_text: string) {}, + sendClaudeSessionMessage(message: unknown) { + summaryMessages.push(message); + }, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); } @@ -252,6 +628,7 @@ function createSessionStub(messages = ['hello from launcher test']) { session, sessionEvents, codexMessages, + summaryMessages, thinkingChanges, foundSessionIds, resetThreadCalls, @@ -278,6 +655,17 @@ describe('codexRemoteLauncher', () => { harness.nextThreadSystemErrorMessage = null; harness.failNextCompact = false; harness.deferThreadStatusNotifications = false; + harness.emitChildThreadEvents = false; + harness.emitChildUsageEvents = false; + harness.emitChildReasoningBurst = false; + harness.emitParentUsageEvents = false; + harness.emitChildNestedAgentTool = false; + harness.emitParentTitleChange = false; + harness.emitParentSpawnFailureWithoutAgentId = false; + harness.emitParentSpawnStartWithoutEnd = false; + harness.emitParentSendInputFailure = false; + harness.emitParentResumeSuccess = false; + harness.bridgeOptions = []; }); it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => { @@ -506,6 +894,310 @@ describe('codexRemoteLauncher', () => { })); }); + it('routes child thread messages into agent-run trace while keeping them out of the parent timeline', async () => { + harness.emitChildThreadEvents = true; + const { session, codexMessages, summaryMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'message', + message: 'child output should stay hidden' + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'tool-call', + callId: 'child-cmd-1' + })); + expect(summaryMessages).not.toContainEqual(expect.objectContaining({ + type: 'summary', + summary: 'Child Title' + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'tool-call', + name: 'wait_agent', + callId: 'wait-child' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-trace', + agentId: 'child-thread', + message: expect.objectContaining({ + type: 'message', + message: 'child output should stay hidden' + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-trace', + agentId: 'child-thread', + message: expect.objectContaining({ + type: 'tool-call', + callId: 'child-cmd-1' + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + activity: 'Running command: echo child', + activityKind: 'running-command' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + summary: 'Child Title' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'completed', + result: 'child output should stay hidden', + activity: 'Completed: child output should stay hidden', + activityKind: 'completed' + })); + }); + + it('surfaces send_input failures on the target child agent card', async () => { + harness.emitChildThreadEvents = true; + harness.emitParentSendInputFailure = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'running', + statusText: 'Sending input', + activity: 'Sending input', + activityKind: 'send_input' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'failed', + statusText: 'Send input failed', + activity: 'Send input failed: send failed', + activityKind: 'failed', + error: expect.objectContaining({ + error: 'send failed' + }) + })); + }); + + it('updates the target child agent card when resume_agent completes', async () => { + harness.emitChildThreadEvents = true; + harness.emitParentResumeSuccess = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'running', + statusText: 'Resuming agent', + activity: 'Resuming agent', + activityKind: 'resume_agent' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'running', + statusText: 'Resumed', + activity: 'Resumed', + activityKind: 'resume_agent', + result: expect.objectContaining({ + status: 'completed', + targets: ['child-thread'] + }) + })); + }); + + it('throttles child agent reasoning activity updates instead of emitting one per delta', async () => { + harness.emitChildThreadEvents = true; + harness.emitChildReasoningBurst = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + const thinkingUpdates = codexMessages.filter((message): message is Record => { + return typeof message === 'object' + && message !== null + && (message as Record).type === 'agent-run-update' + && (message as Record).agentId === 'child-thread' + && (message as Record).activityKind === 'thinking'; + }); + + expect(thinkingUpdates.length).toBeLessThan(20); + expect(thinkingUpdates.length).toBeLessThanOrEqual(1); + }); + + it('keeps child usage and compact events out of the parent context stream', async () => { + harness.emitChildThreadEvents = true; + harness.emitChildUsageEvents = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'token_count', + thread_id: 'child-thread' + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'token_count', + info: expect.objectContaining({ + last_token_usage: expect.objectContaining({ + input_tokens: 999 + }) + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-trace', + agentId: 'child-thread', + message: expect.objectContaining({ + type: 'context_compacted' + }) + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'context_compacted', + thread_id: 'child-thread' + })); + }); + + it('marks parent usage and compact events with parent scope', async () => { + harness.emitParentUsageEvents = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'token_count', + thread_id: 'thread-1', + scope_role: 'parent', + scope: expect.objectContaining({ + role: 'parent', + thread_id: 'thread-1' + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'context_compacted', + thread_id: 'thread-1', + scope_role: 'parent', + scope: expect.objectContaining({ + role: 'parent', + thread_id: 'thread-1' + }) + })); + }); + + it('marks child agents failed when they attempt to start nested agents', async () => { + harness.emitChildThreadEvents = true; + harness.emitChildNestedAgentTool = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-trace', + agentId: 'child-thread', + message: expect.objectContaining({ + type: 'tool-call', + name: 'spawn_agent', + callId: 'nested-spawn' + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-trace', + agentId: 'child-thread', + message: expect.objectContaining({ + type: 'tool-call-result', + callId: 'nested-spawn', + is_error: true, + output: 'Nested agent calls are disabled for child agents.' + }) + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'child-thread', + status: 'failed', + activity: 'Failed: Nested agent calls are disabled for child agents.', + activityKind: 'failed' + })); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'grandchild-thread' + })); + }); + + it('marks spawn_agent cards failed when Codex returns no agent id', async () => { + harness.emitParentSpawnFailureWithoutAgentId = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-start', + cardId: 'failed-spawn', + status: 'starting' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'spawn-error:failed-spawn', + cardId: 'failed-spawn', + status: 'failed', + statusText: 'Failed to start', + activityKind: 'failed', + error: expect.objectContaining({ + status: 'failed', + error: 'invalid spawn arguments' + }) + })); + }); + + it('marks pending spawn_agent cards failed when the session ends before a result', async () => { + harness.emitParentSpawnStartWithoutEnd = true; + const { session, codexMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-start', + cardId: 'failed-spawn', + status: 'starting' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'agent-run-update', + agentId: 'spawn-error:failed-spawn', + cardId: 'failed-spawn', + status: 'failed', + statusText: 'Failed to start', + activityKind: 'failed', + error: 'spawn_agent did not return an agent id before the Codex session ended' + })); + }); + + it('applies parent-thread hapi change_title after disabling MCP-side title writes', async () => { + harness.emitParentTitleChange = true; + const { session, codexMessages, summaryMessages } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(harness.bridgeOptions).toEqual([{ emitTitleSummary: false }]); + expect(summaryMessages).toContainEqual(expect.objectContaining({ + type: 'summary', + summary: 'Parent Title' + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'tool-call', + name: 'mcp__hapi__change_title', + callId: 'title-parent', + input: { title: 'Parent Title' } + })); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'tool-call-result', + callId: 'title-parent', + is_error: false + })); + }); + it('clears codex thread state without starting a turn', async () => { const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index cdfd1e7f..32b43bb4 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -25,6 +25,23 @@ import { type HappyServer = Awaited>['server']; type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +type ChildAgentRuntime = { + reasoningProcessor: ReasoningProcessor; + diffProcessor: DiffProcessor; + activeToolsByCallId: Map; + pendingTitleByCallId: Map; + reasoningPreview: string; + blockedNestedAgent: boolean; +}; + +const AGENT_RUN_UPDATE_THROTTLE_MS = 300; +const AGENT_RUN_START_TIMEOUT_MS = 30 * 1000; +const THROTTLED_AGENT_RUN_ACTIVITY_KINDS = new Set(['thinking']); const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [ 'selected model is at capacity', @@ -189,6 +206,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return `mcp__${serverName}__${toolName}`; }; + const isHapiChangeTitleToolName = (toolName: string | null): boolean => { + return toolName === 'mcp__hapi__change_title'; + }; + + const sendTitleSummary = (title: string): void => { + session.client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }); + }; + const formatOutputPreview = (value: unknown): string => { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); @@ -200,6 +229,117 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }; + const compactText = (text: string): string => { + return text.replace(/\s+/g, ' ').trim(); + }; + + const truncateText = (text: string, maxLength: number): string => { + const compacted = compactText(text); + if (compacted.length <= maxLength) return compacted; + return `${compacted.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + }; + + const previewText = (value: unknown, maxLength = 120): string | null => { + const text = formatOutputPreview(value); + const preview = truncateText(text, maxLength); + return preview.length > 0 ? preview : null; + }; + + const formatActivity = (verb: string, detail?: string | null, maxLength = 120): string => { + const normalizedDetail = detail ? truncateText(detail, maxLength) : ''; + return normalizedDetail ? `${verb}: ${normalizedDetail}` : verb; + }; + + const extractTextItems = (input: unknown): string[] => { + const record = asRecord(input); + if (!record || !Array.isArray(record.items)) return []; + return record.items + .map((item) => { + const itemRecord = asRecord(item); + return asString(itemRecord?.text); + }) + .filter((text): text is string => Boolean(text)); + }; + + const extractAgentPrompt = (input: unknown): string | null => { + const record = asRecord(input); + if (!record) return null; + + const direct = asString(record.message ?? record.prompt); + if (direct) return direct; + + const textItems = extractTextItems(input); + return textItems.length > 0 ? textItems.join('\n\n') : null; + }; + + const cleanAgentPromptForSummary = (prompt: string): string => { + const withoutTags = prompt + .replace(/<[^>\n]+>/g, ' ') + .replace(/\r/g, '\n'); + const noisePatterns = [ + /not alone in the codebase/i, + /do not revert/i, + /don't revert/i, + /list the file paths/i, + /changed files/i, + /final answer/i, + /avoid merge conflicts/i, + /accommodate the changes/i + ]; + const lines = withoutTags + .split('\n') + .map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/^#{1,6}\s+/, '')) + .filter((line) => line.length > 0) + .filter((line) => !noisePatterns.some((pattern) => pattern.test(line))); + const candidate = lines.length > 0 ? lines.join(' ') : withoutTags; + return compactText(candidate) + .replace(/^(task|your task|request|prompt)\s*[::]\s*/i, '') + .trim(); + }; + + const summarizeAgentInput = (input: unknown): string | null => { + const prompt = extractAgentPrompt(input); + if (prompt) { + const cleaned = cleanAgentPromptForSummary(prompt); + if (cleaned.length > 0) { + return truncateText(cleaned, 80); + } + } + + const record = asRecord(input); + const agentType = asString(record?.agent_type ?? record?.subagent_type ?? record?.type); + return agentType ? `${agentType} agent` : null; + }; + + const getPatchFiles = (changes: unknown): string[] => { + const record = asRecord(changes); + if (!record) return []; + return Object.keys(record).filter((file) => file.length > 0); + }; + + const summarizeFiles = (files: string[]): string | null => { + if (files.length === 0) return null; + const first = files[0] ?? ''; + const basename = first.split('/').filter(Boolean).pop() ?? first; + return files.length > 1 ? `${basename} (+${files.length - 1})` : basename; + }; + + const summarizeDiffFiles = (diff: string): string | null => { + const files: string[] = []; + for (const line of diff.split('\n')) { + if (!line.startsWith('+++ ')) continue; + const file = line.replace(/^\+\+\+ (b\/)?/, '').trim(); + if (file && file !== '/dev/null') files.push(file); + } + return summarizeFiles([...new Set(files)]); + }; + + const displayMcpToolName = (toolName: string): string => { + const match = toolName.match(/^mcp__(.+?)__(.+)$/); + if (!match) return toolName; + return `${match[1]}.${match[2]}`; + }; + const permissionHandler = new CodexPermissionHandler(session.client, () => { const mode = session.getPermissionMode(); return mode === 'default' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo' @@ -263,6 +403,26 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const diffProcessor = new DiffProcessor((message) => { session.sendAgentMessage(message); }); + const mcpTitleByCallId = new Map(); + const agentCardByAgentId = new Map(); + const agentSummaryByCardId = new Map(); + const agentSummaryByAgentId = new Map(); + const agentStatusByAgentId = new Map(); + const agentStartedAtByCardId = new Map(); + const agentStartedAtByAgentId = new Map(); + const pendingAgentStartCardIds = new Set(); + const pendingAgentUpdatesByAgentId = new Map[]>(); + const pendingAgentTracesByAgentId = new Map(); + const pendingAgentToolInputByCallId = new Map(); + const childAgentRuntimeById = new Map(); + const lastAgentRunUpdateAtByAgentId = new Map(); + const lastAgentRunUpdateSignatureByAgentId = new Map(); + const pendingThrottledAgentUpdateByAgentId = new Map; + cardIdOverride?: string | null; + }>(); + const pendingThrottledAgentUpdateTimerByAgentId = new Map>(); + const pendingAgentStartTimersByCardId = new Map>(); this.permissionHandler = permissionHandler; this.reasoningProcessor = reasoningProcessor; this.diffProcessor = diffProcessor; @@ -272,6 +432,961 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let turnInFlight = false; let allowAnonymousTerminalEvent = false; let invalidThreadId: string | null = null; + let childAgentActivityInCurrentTurn = false; + + const isCodexAgentToolName = (toolName: string | null): boolean => { + return toolName === 'spawn_agent' + || toolName === 'send_input' + || toolName === 'resume_agent' + || toolName === 'wait_agent' + || toolName === 'close_agent'; + }; + + const isTerminalAgentRunStatus = (status: string | null | undefined): boolean => { + return status === 'completed' + || status === 'failed' + || status === 'error' + || status === 'canceled' + || status === 'cancelled' + || status === 'notFound' + || status === 'not_found'; + }; + + const isCloseAgentCleanupUpdate = (update: Record): boolean => { + const activityKind = asString(update.activityKind ?? update.activity_kind); + if (activityKind === 'close_agent' || activityKind === 'closed') return true; + return activityKind === 'canceled' + && (asString(update.activity) === 'Closed' || asString(update.statusText ?? update.status_text) === 'Closed'); + }; + + const isScopeSensitiveCodexEvent = (type: string): boolean => { + return type === 'token_count' || type === 'context_compacted'; + }; + + const hasKnownChildAgents = (): boolean => { + if (childAgentActivityInCurrentTurn) return true; + if (pendingAgentStartCardIds.size > 0) return true; + for (const agentId of new Set([...agentCardByAgentId.keys(), ...childAgentRuntimeById.keys()])) { + const status = agentStatusByAgentId.get(agentId); + if (!isTerminalAgentRunStatus(status)) { + return true; + } + } + return false; + }; + + const buildCodexEventScope = ( + threadId: string | null, + role: 'parent' | 'child', + agentId?: string | null + ): Record => ({ + role, + ...(threadId ? { threadId, thread_id: threadId } : {}), + ...(this.currentThreadId ? { parentThreadId: this.currentThreadId, parent_thread_id: this.currentThreadId } : {}), + ...(agentId ? { agentId, agent_id: agentId } : {}) + }); + + const addCodexEventScope = ( + event: Record, + role: 'parent' | 'child', + threadId: string | null, + agentId?: string | null + ): Record => ({ + ...event, + ...(threadId ? { threadId, thread_id: threadId } : {}), + scopeRole: role, + scope_role: role, + scope: buildCodexEventScope(threadId, role, agentId) + }); + + const extractAgentTargets = (input: unknown): string[] => { + const record = asRecord(input); + if (!record) return []; + const targets = Array.isArray(record.targets) + ? record.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : []; + if (targets.length > 0) return targets; + return [record.target, record.agent_id, record.agentId, record.id] + .filter((target): target is string => typeof target === 'string' && target.length > 0); + }; + + const emitAgentRunEvent = (event: Record): void => { + const agentId = asString(event.agentId ?? event.agent_id); + session.sendAgentMessage({ + ...(agentId ? addCodexEventScope(event, 'child', agentId, agentId) : event), + id: randomUUID() + }); + }; + + const clearPendingAgentStart = (cardId: string): void => { + const timer = pendingAgentStartTimersByCardId.get(cardId); + if (timer) { + clearTimeout(timer); + } + pendingAgentStartTimersByCardId.delete(cardId); + pendingAgentStartCardIds.delete(cardId); + }; + + let failAgentStartCard = (_cardId: string, _error: unknown): void => {}; + + const emitAgentRunStart = (cardId: string, input: unknown): void => { + childAgentActivityInCurrentTurn = true; + const startedAt = Date.now(); + agentStartedAtByCardId.set(cardId, startedAt); + const summary = summarizeAgentInput(input); + if (summary) { + agentSummaryByCardId.set(cardId, summary); + } + clearPendingAgentStart(cardId); + pendingAgentStartCardIds.add(cardId); + const timer = setTimeout(() => { + failAgentStartCard( + cardId, + `spawn_agent did not return an agent id within ${AGENT_RUN_START_TIMEOUT_MS / 1000}s` + ); + }, AGENT_RUN_START_TIMEOUT_MS); + timer.unref?.(); + pendingAgentStartTimersByCardId.set(cardId, timer); + emitAgentRunEvent({ + type: 'agent-run-start', + cardId, + input, + startedAt, + status: 'starting', + statusText: 'Starting', + activity: 'Starting', + activityKind: 'starting', + ...(summary ? { summary } : {}) + }); + }; + + const flushPendingAgentTraces = (agentId: string): void => { + const traces = pendingAgentTracesByAgentId.get(agentId); + if (!traces || traces.length === 0) return; + pendingAgentTracesByAgentId.delete(agentId); + for (const message of traces) { + emitAgentRunEvent({ + type: 'agent-run-trace', + agentId, + cardId: agentCardByAgentId.get(agentId), + ...(agentStartedAtByAgentId.has(agentId) ? { startedAt: agentStartedAtByAgentId.get(agentId) } : {}), + message + }); + } + }; + + const linkAgentToCard = (agentId: string, cardId: string): void => { + agentCardByAgentId.set(agentId, cardId); + clearPendingAgentStart(cardId); + const startedAt = agentStartedAtByCardId.get(cardId) ?? agentStartedAtByAgentId.get(agentId); + if (startedAt) { + agentStartedAtByCardId.set(cardId, startedAt); + agentStartedAtByAgentId.set(agentId, startedAt); + } + const summary = agentSummaryByCardId.get(cardId); + if (summary) { + agentSummaryByAgentId.set(agentId, summary); + } + flushPendingAgentTraces(agentId); + }; + + const flushPendingAgentUpdates = (agentId: string): void => { + const updates = pendingAgentUpdatesByAgentId.get(agentId); + if (!updates || updates.length === 0) return; + pendingAgentUpdatesByAgentId.delete(agentId); + for (const update of updates) { + emitAgentRunUpdate(agentId, update); + } + }; + + const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`; + }; + + const getAgentRunUpdateSignature = ( + agentId: string, + update: Record, + cardIdOverride?: string | null + ): string => stableStringify({ + agentId, + cardIdOverride: cardIdOverride ?? null, + update + }); + + const cancelPendingThrottledAgentRunUpdate = (agentId: string): void => { + const timer = pendingThrottledAgentUpdateTimerByAgentId.get(agentId); + if (timer) { + clearTimeout(timer); + } + pendingThrottledAgentUpdateTimerByAgentId.delete(agentId); + pendingThrottledAgentUpdateByAgentId.delete(agentId); + }; + + const cancelAllPendingThrottledAgentRunUpdates = (): void => { + for (const agentId of Array.from(pendingThrottledAgentUpdateTimerByAgentId.keys())) { + cancelPendingThrottledAgentRunUpdate(agentId); + } + pendingThrottledAgentUpdateByAgentId.clear(); + }; + + const flushPendingThrottledAgentRunUpdate = (agentId: string): void => { + const pendingUpdate = pendingThrottledAgentUpdateByAgentId.get(agentId); + pendingThrottledAgentUpdateTimerByAgentId.delete(agentId); + pendingThrottledAgentUpdateByAgentId.delete(agentId); + if (!pendingUpdate) return; + emitAgentRunUpdateNow(agentId, pendingUpdate.update, pendingUpdate.cardIdOverride); + }; + + const scheduleThrottledAgentRunUpdate = ( + agentId: string, + update: Record, + cardIdOverride?: string | null + ): void => { + pendingThrottledAgentUpdateByAgentId.set(agentId, { update, cardIdOverride }); + if (pendingThrottledAgentUpdateTimerByAgentId.has(agentId)) { + return; + } + + const lastAt = lastAgentRunUpdateAtByAgentId.get(agentId) ?? 0; + const delay = Math.max(AGENT_RUN_UPDATE_THROTTLE_MS - (Date.now() - lastAt), 0); + const timer = setTimeout(() => { + flushPendingThrottledAgentRunUpdate(agentId); + }, delay); + timer.unref?.(); + pendingThrottledAgentUpdateTimerByAgentId.set(agentId, timer); + }; + + const emitAgentRunUpdateNow = ( + agentId: string, + update: Record, + cardIdOverride?: string | null + ): void => { + const knownCardId = agentCardByAgentId.get(agentId); + if ( + !cardIdOverride + && !knownCardId + && pendingAgentStartCardIds.size > 0 + && childAgentRuntimeById.has(agentId) + ) { + const updates = pendingAgentUpdatesByAgentId.get(agentId) ?? []; + updates.push(update); + pendingAgentUpdatesByAgentId.set(agentId, updates); + return; + } + + const cardId = cardIdOverride ?? knownCardId ?? `codex-agent:${agentId}`; + if (!knownCardId) { + agentCardByAgentId.set(agentId, cardId); + } + const startedAt = agentStartedAtByAgentId.get(agentId) + ?? agentStartedAtByCardId.get(cardId) + ?? Date.now(); + agentStartedAtByAgentId.set(agentId, startedAt); + agentStartedAtByCardId.set(cardId, startedAt); + const nextStatus = asString(update.status); + const currentStatus = agentStatusByAgentId.get(agentId); + const activityKind = asString(update.activityKind ?? update.activity_kind); + if ( + isTerminalAgentRunStatus(currentStatus) + && !isTerminalAgentRunStatus(nextStatus) + && (activityKind === 'wait_agent' || activityKind === 'close_agent') + ) { + return; + } + if ( + childAgentRuntimeById.get(agentId)?.blockedNestedAgent + && nextStatus !== 'failed' + && nextStatus !== 'error' + ) { + return; + } + if ( + isTerminalAgentRunStatus(currentStatus) + && nextStatus !== 'failed' + && nextStatus !== 'error' + && isCloseAgentCleanupUpdate(update) + ) { + return; + } + const nextSummary = asString(update.summary); + if (nextSummary) { + agentSummaryByAgentId.set(agentId, nextSummary); + agentSummaryByCardId.set(cardId, nextSummary); + } + if (nextStatus) { + agentStatusByAgentId.set(agentId, nextStatus); + } + const event = { + type: 'agent-run-update', + agentId, + cardId, + startedAt, + ...(isTerminalAgentRunStatus(nextStatus) ? { completedAt: Date.now() } : {}), + ...(agentSummaryByAgentId.has(agentId) ? { summary: agentSummaryByAgentId.get(agentId) } : {}), + ...update + }; + const signature = getAgentRunUpdateSignature(agentId, event, cardIdOverride); + if (lastAgentRunUpdateSignatureByAgentId.get(agentId) === signature) { + return; + } + lastAgentRunUpdateSignatureByAgentId.set(agentId, signature); + lastAgentRunUpdateAtByAgentId.set(agentId, Date.now()); + emitAgentRunEvent(event); + flushPendingAgentTraces(agentId); + }; + + const emitAgentRunUpdate = ( + agentId: string, + update: Record, + cardIdOverride?: string | null + ): void => { + const nextStatus = asString(update.status); + const terminal = isTerminalAgentRunStatus(nextStatus); + if (terminal) { + cancelPendingThrottledAgentRunUpdate(agentId); + emitAgentRunUpdateNow(agentId, update, cardIdOverride); + return; + } + + const activityKind = asString(update.activityKind ?? update.activity_kind); + if (!activityKind || !THROTTLED_AGENT_RUN_ACTIVITY_KINDS.has(activityKind)) { + emitAgentRunUpdateNow(agentId, update, cardIdOverride); + return; + } + + const lastAt = lastAgentRunUpdateAtByAgentId.get(agentId); + if (lastAt === undefined || Date.now() - lastAt >= AGENT_RUN_UPDATE_THROTTLE_MS) { + emitAgentRunUpdateNow(agentId, update, cardIdOverride); + return; + } + + scheduleThrottledAgentRunUpdate(agentId, update, cardIdOverride); + }; + + failAgentStartCard = (cardId: string, error: unknown): void => { + if (!pendingAgentStartCardIds.has(cardId) && !pendingAgentStartTimersByCardId.has(cardId)) { + return; + } + + const agentId = `spawn-error:${cardId}`; + linkAgentToCard(agentId, cardId); + emitAgentRunUpdate(agentId, { + status: 'failed', + statusText: 'Failed to start', + activity: formatActivity('Failed to start', previewText(error)), + activityKind: 'failed', + error + }, cardId); + }; + + const failPendingAgentStarts = (error: unknown): void => { + for (const cardId of Array.from(pendingAgentStartCardIds)) { + failAgentStartCard(cardId, error); + } + }; + + const emitAgentRunTraceMessage = (agentId: string, message: unknown): void => { + const cardId = agentCardByAgentId.get(agentId); + if (!cardId) { + const traces = pendingAgentTracesByAgentId.get(agentId) ?? []; + traces.push(message); + pendingAgentTracesByAgentId.set(agentId, traces); + return; + } + emitAgentRunEvent({ + type: 'agent-run-trace', + agentId, + cardId, + ...(agentStartedAtByAgentId.has(agentId) ? { startedAt: agentStartedAtByAgentId.get(agentId) } : {}), + message + }); + }; + + const getChildRuntime = (agentId: string) => { + const existing = childAgentRuntimeById.get(agentId); + if (existing) return existing; + const runtime = { + reasoningProcessor: new ReasoningProcessor((message) => { + emitAgentRunTraceMessage(agentId, message); + }), + diffProcessor: new DiffProcessor((message) => { + emitAgentRunTraceMessage(agentId, message); + }), + activeToolsByCallId: new Map(), + pendingTitleByCallId: new Map(), + reasoningPreview: '', + blockedNestedAgent: false + }; + childAgentRuntimeById.set(agentId, runtime); + return runtime; + }; + + const normalizeAgentStatusUpdate = (value: unknown): Record => { + if (typeof value === 'string') { + const activity = formatActivity('Completed', previewText(value)); + return { + status: 'completed', + statusText: 'Completed', + activity, + activityKind: 'completed', + result: value + }; + } + + const record = asRecord(value); + if (!record) { + const activity = formatActivity('Completed', previewText(value)); + return { + status: 'completed', + statusText: 'Completed', + activity, + activityKind: 'completed', + result: value + }; + } + + const completed = asString(record.completed); + if (completed) { + return { + status: 'completed', + statusText: 'Completed', + activity: formatActivity('Completed', completed), + activityKind: 'completed', + result: completed + }; + } + const failed = asString(record.failed ?? record.error); + if (failed) { + return { + status: 'failed', + statusText: 'Failed', + activity: formatActivity('Failed', failed), + activityKind: 'failed', + error: failed + }; + } + const canceled = asString(record.canceled ?? record.cancelled); + if (canceled) { + return { + status: 'canceled', + statusText: 'Canceled', + activity: formatActivity('Canceled', canceled), + activityKind: 'canceled', + error: canceled + }; + } + + const rawStatus = asString(record.status ?? record.state); + if (rawStatus === 'notFound' || rawStatus === 'not_found') { + const error = record.message ?? record.error ?? value; + return { + status: 'failed', + statusText: 'Not found', + activity: formatActivity('Agent not found', previewText(error)), + activityKind: 'not_found', + error + }; + } + if (rawStatus === 'completed') { + const result = record.message ?? record.output ?? value; + return { + status: 'completed', + statusText: 'Completed', + activity: formatActivity('Completed', previewText(result)), + activityKind: 'completed', + result + }; + } + if (rawStatus === 'failed' || rawStatus === 'error') { + const error = record.message ?? record.error ?? value; + return { + status: 'failed', + statusText: 'Failed', + activity: formatActivity('Failed', previewText(error)), + activityKind: 'failed', + error + }; + } + if (rawStatus === 'canceled' || rawStatus === 'cancelled') { + const error = record.message ?? record.error ?? value; + return { + status: 'canceled', + statusText: 'Canceled', + activity: formatActivity('Canceled', previewText(error)), + activityKind: 'canceled', + error + }; + } + + return { + status: rawStatus ?? 'running', + statusText: rawStatus ?? 'Running', + activity: formatActivity(rawStatus ?? 'Running', previewText(record.message ?? record.output ?? value)), + activityKind: rawStatus ?? 'running', + result: value + }; + }; + + const isAgentNotFoundStatusUpdate = (update: Record): boolean => { + const status = asString(update.status); + const activityKind = asString(update.activityKind ?? update.activity_kind); + return status === 'notFound' + || status === 'not_found' + || activityKind === 'not_found'; + }; + + const handleAgentToolEnd = (callId: string, name: string, output: unknown, isError: boolean): void => { + const pending = pendingAgentToolInputByCallId.get(callId); + pendingAgentToolInputByCallId.delete(callId); + + if (name === 'spawn_agent') { + childAgentActivityInCurrentTurn = true; + const outputRecord = asRecord(output); + const agentsStates = asRecord(outputRecord?.agentsStates ?? outputRecord?.agents_states); + const agentIdsFromState = agentsStates ? Object.keys(agentsStates) : []; + const agentId = asString(outputRecord?.agent_id ?? outputRecord?.agentId ?? outputRecord?.id) + ?? (agentIdsFromState.length === 1 ? agentIdsFromState[0] : null) + ?? extractAgentTargets(pending?.input).at(0); + if (!agentId) { + const detail = isError + ? output + : { + message: 'spawn_agent completed without returning an agent id', + output + }; + failAgentStartCard(callId, detail); + return; + } + linkAgentToCard(agentId, callId); + emitAgentRunUpdate(agentId, { + status: isError ? 'failed' : 'running', + statusText: isError ? 'Failed to start' : 'Running', + activity: isError ? formatActivity('Failed to start', previewText(output)) : 'Started', + activityKind: isError ? 'failed' : 'running', + ...(isError ? { error: output } : { spawnResult: output }) + }, callId); + flushPendingAgentUpdates(agentId); + return; + } + + if (name === 'wait_agent') { + childAgentActivityInCurrentTurn = true; + const outputRecord = asRecord(output); + const statusMap = asRecord(outputRecord?.status) ?? {}; + for (const [agentId, statusValue] of Object.entries(statusMap)) { + const update = normalizeAgentStatusUpdate(statusValue); + if (!agentCardByAgentId.has(agentId) && isAgentNotFoundStatusUpdate(update)) { + continue; + } + emitAgentRunUpdate(agentId, update); + } + return; + } + + if (name === 'send_input' || name === 'resume_agent') { + childAgentActivityInCurrentTurn = true; + const outputRecord = asRecord(output); + const targets = Array.from(new Set([ + ...extractAgentTargets(pending?.input), + ...extractAgentTargets(outputRecord) + ])); + const label = name === 'send_input' ? 'Send input' : 'Resume'; + const successActivity = name === 'send_input' ? 'Input sent' : 'Resumed'; + const errorDetail = asString(outputRecord?.error ?? outputRecord?.message) ?? output; + + for (const agentId of targets) { + if (!agentCardByAgentId.has(agentId)) continue; + emitAgentRunUpdate(agentId, { + status: isError ? 'failed' : 'running', + statusText: isError ? `${label} failed` : successActivity, + activity: isError ? formatActivity(`${label} failed`, previewText(errorDetail)) : successActivity, + activityKind: isError ? 'failed' : name, + ...(isError ? { error: output } : { result: output }) + }); + } + return; + } + + if (name === 'close_agent') { + const outputRecord = asRecord(output); + const agentId = asString(outputRecord?.agent_id ?? outputRecord?.agentId) + ?? extractAgentTargets(pending?.input).at(0); + if (!agentId) return; + emitAgentRunUpdate(agentId, { + status: isError ? 'failed' : 'completed', + statusText: isError ? 'Close failed' : 'Closed', + activity: isError ? formatActivity('Close failed', previewText(output)) : 'Closed', + activityKind: isError ? 'failed' : 'closed', + ...(isError ? { error: output } : { result: output }) + }); + return; + } + }; + + const handleChildCodexEvent = (agentId: string, msg: Record): void => { + const msgType = asString(msg.type); + if (!msgType) return; + childAgentActivityInCurrentTurn = true; + const runtime = getChildRuntime(agentId); + const isChildTerminalEvent = msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed'; + if (runtime.blockedNestedAgent && !isChildTerminalEvent) { + return; + } + const updateActivity = ( + activity: string, + activityKind: string, + extra?: Record + ): void => { + emitAgentRunUpdate(agentId, { + status: 'running', + statusText: activity, + activity, + activityKind, + ...extra + }); + }; + + if (msgType === 'token_count') { + return; + } + + if (msgType === 'context_compacted') { + emitAgentRunTraceMessage(agentId, { + type: 'context_compacted', + id: randomUUID() + }); + updateActivity('Context compacted', 'compact'); + return; + } + + if (msgType === 'task_started') { + runtime.reasoningPreview = ''; + updateActivity('Starting task', 'starting'); + return; + } + if (msgType === 'agent_reasoning_section_break') { + runtime.reasoningProcessor.handleSectionBreak(); + runtime.reasoningPreview = ''; + updateActivity('Thinking', 'thinking'); + return; + } + if (msgType === 'agent_reasoning_delta') { + const delta = asString(msg.delta); + if (delta) { + runtime.reasoningProcessor.processDelta(delta); + runtime.reasoningPreview = truncateText(`${runtime.reasoningPreview}${delta}`, 160); + } + updateActivity(formatActivity('Thinking', runtime.reasoningPreview || null), 'thinking'); + return; + } + if (msgType === 'agent_reasoning') { + const text = asString(msg.text); + if (text) { + runtime.reasoningProcessor.complete(text); + runtime.reasoningPreview = truncateText(text, 160); + } + updateActivity(formatActivity('Thinking', runtime.reasoningPreview || null), 'thinking'); + return; + } + if (msgType === 'agent_message') { + const message = asString(msg.message); + if (message) { + emitAgentRunTraceMessage(agentId, { + type: 'message', + message, + id: randomUUID() + }); + } + updateActivity(formatActivity('Writing', message), 'writing'); + return; + } + if (msgType === 'exec_command_begin' || msgType === 'exec_approval_request') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const inputs: Record = { ...msg }; + delete inputs.type; + delete inputs.call_id; + delete inputs.callId; + emitAgentRunTraceMessage(agentId, { + type: 'tool-call', + name: 'CodexBash', + callId, + input: inputs, + id: randomUUID() + }); + const command = normalizeCommand(inputs.command) ?? 'command'; + runtime.activeToolsByCallId.set(callId, { + name: 'CodexBash', + label: command, + activity: formatActivity('Running command', command), + activityKind: 'running-command' + }); + emitAgentRunUpdate(agentId, { + status: 'running', + statusText: formatActivity('Running command', command), + activity: formatActivity('Running command', command), + activityKind: 'running-command' + }); + } + return; + } + if (msgType === 'exec_command_end') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const activeTool = runtime.activeToolsByCallId.get(callId); + runtime.activeToolsByCallId.delete(callId); + const output: Record = { ...msg }; + delete output.type; + delete output.call_id; + delete output.callId; + output.stdout = output.output; + delete output.output; + emitAgentRunTraceMessage(agentId, { + type: 'tool-call-result', + callId, + output, + is_error: Boolean(output.error), + id: randomUUID() + }); + const label = activeTool?.label ?? normalizeCommand(output.command) ?? 'command'; + const isError = Boolean(output.error); + updateActivity( + formatActivity(isError ? 'Command failed' : 'Command finished', label), + isError ? 'command-failed' : 'command-completed' + ); + } + return; + } + if (msgType === 'patch_apply_begin') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const changes = asRecord(msg.changes) ?? {}; + const files = getPatchFiles(changes); + const fileSummary = summarizeFiles(files); + emitAgentRunTraceMessage(agentId, { + type: 'tool-call', + name: 'CodexPatch', + callId, + input: { + auto_approved: msg.auto_approved ?? msg.autoApproved, + changes + }, + id: randomUUID() + }); + runtime.activeToolsByCallId.set(callId, { + name: 'CodexPatch', + label: fileSummary ?? 'files', + activity: formatActivity('Editing files', fileSummary), + activityKind: 'editing' + }); + updateActivity(formatActivity('Editing files', fileSummary), 'editing'); + } + return; + } + if (msgType === 'patch_apply_end') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const activeTool = runtime.activeToolsByCallId.get(callId); + runtime.activeToolsByCallId.delete(callId); + const stdout = asString(msg.stdout); + const stderr = asString(msg.stderr); + const success = Boolean(msg.success); + emitAgentRunTraceMessage(agentId, { + type: 'tool-call-result', + callId, + output: { stdout, stderr, success }, + is_error: !success, + id: randomUUID() + }); + updateActivity( + formatActivity(success ? 'Files edited' : 'Edit failed', activeTool?.label ?? previewText(stderr ?? stdout)), + success ? 'edited' : 'edit-failed' + ); + } + return; + } + if (msgType === 'mcp_tool_call_begin') { + const callId = asString(msg.call_id ?? msg.callId); + const invocation = asRecord(msg.invocation) ?? {}; + const name = buildMcpToolName( + invocation.server ?? invocation.server_name ?? msg.server, + invocation.tool ?? invocation.tool_name ?? msg.tool + ); + if (callId && name) { + const input = invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {}; + const inputRecord = asRecord(input); + const requestedTitle = inputRecord ? asString(inputRecord.title) : null; + if (isHapiChangeTitleToolName(name) && requestedTitle) { + runtime.pendingTitleByCallId.set(callId, requestedTitle); + } + emitAgentRunTraceMessage(agentId, { + type: 'tool-call', + name, + callId, + input, + id: randomUUID() + }); + const label = displayMcpToolName(name); + runtime.activeToolsByCallId.set(callId, { + name, + label, + activity: formatActivity('Calling tool', label), + activityKind: 'tool' + }); + updateActivity(formatActivity('Calling tool', label), 'tool'); + } + return; + } + if (msgType === 'mcp_tool_call_end') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const activeTool = runtime.activeToolsByCallId.get(callId); + runtime.activeToolsByCallId.delete(callId); + const rawResult = msg.result; + let output = rawResult; + let isError = false; + const resultRecord = asRecord(rawResult); + if (resultRecord) { + if (Object.prototype.hasOwnProperty.call(resultRecord, 'Ok')) { + output = resultRecord.Ok; + } else if (Object.prototype.hasOwnProperty.call(resultRecord, 'Err')) { + output = resultRecord.Err; + isError = true; + } + } + emitAgentRunTraceMessage(agentId, { + type: 'tool-call-result', + callId, + output, + is_error: isError, + id: randomUUID() + }); + const title = runtime.pendingTitleByCallId.get(callId); + runtime.pendingTitleByCallId.delete(callId); + updateActivity( + formatActivity(isError ? 'Tool failed' : 'Tool finished', activeTool?.label ?? displayMcpToolName(asString(msg.tool) ?? 'tool')), + isError ? 'tool-failed' : 'tool-completed', + !isError && title ? { summary: title } : undefined + ); + } + return; + } + if (msgType === 'codex_tool_call_begin') { + const callId = asString(msg.call_id ?? msg.callId); + const name = asString(msg.name); + if (callId && name) { + if (isCodexAgentToolName(name)) { + const error = 'Nested agent calls are disabled for child agents.'; + runtime.blockedNestedAgent = true; + emitAgentRunTraceMessage(agentId, { + type: 'tool-call', + name, + callId, + input: msg.input ?? {}, + id: randomUUID() + }); + emitAgentRunTraceMessage(agentId, { + type: 'tool-call-result', + callId, + output: error, + is_error: true, + id: randomUUID() + }); + emitAgentRunUpdate(agentId, { + status: 'failed', + statusText: 'Failed', + activity: formatActivity('Failed', error), + activityKind: 'failed', + error + }); + return; + } + const activity = formatActivity('Running tool', name); + emitAgentRunTraceMessage(agentId, { + type: 'tool-call', + name, + callId, + input: msg.input ?? {}, + id: randomUUID() + }); + runtime.activeToolsByCallId.set(callId, { + name, + label: name, + activity, + activityKind: 'tool' + }); + updateActivity(activity, 'tool'); + } + return; + } + if (msgType === 'codex_tool_call_end') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const activeTool = runtime.activeToolsByCallId.get(callId); + runtime.activeToolsByCallId.delete(callId); + const isError = Boolean(msg.is_error ?? msg.isError); + emitAgentRunTraceMessage(agentId, { + type: 'tool-call-result', + callId, + output: msg.output, + is_error: isError, + id: randomUUID() + }); + updateActivity( + formatActivity(isError ? 'Tool failed' : 'Tool finished', activeTool?.label ?? 'tool'), + isError ? 'tool-failed' : 'tool-completed' + ); + } + return; + } + if (msgType === 'turn_diff') { + const diff = asString(msg.unified_diff); + if (diff) { + runtime.diffProcessor.processDiff(diff); + updateActivity(formatActivity('Editing files', summarizeDiffFiles(diff)), 'editing'); + } + return; + } + if (isChildTerminalEvent) { + runtime.reasoningProcessor.reset(); + runtime.diffProcessor.reset(); + runtime.activeToolsByCallId.clear(); + runtime.pendingTitleByCallId.clear(); + runtime.reasoningPreview = ''; + if (msgType === 'task_failed') { + const error = asString(msg.error) ?? 'Task failed'; + emitAgentRunUpdate(agentId, { + status: 'failed', + statusText: 'Failed', + activity: formatActivity('Failed', error), + activityKind: 'failed', + error + }); + } else if (msgType === 'turn_aborted') { + emitAgentRunUpdate(agentId, { + status: 'canceled', + statusText: 'Canceled', + activity: 'Canceled', + activityKind: 'canceled' + }); + } else { + emitAgentRunUpdate(agentId, { + status: 'completed', + statusText: 'Completed', + activity: 'Completed', + activityKind: 'completed' + }); + } + } + }; + let activeMessage: QueuedMessage | null = null; let sameThreadRetryAttempt = 0; let sameThreadCompactAttempt = 0; @@ -394,8 +1509,15 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (msgType === 'thread_started') { const threadId = asString(msg.thread_id ?? msg.threadId); if (threadId) { - this.currentThreadId = threadId; - session.onSessionFound(threadId); + if (!this.currentThreadId || this.currentThreadId === threadId) { + this.currentThreadId = threadId; + session.onSessionFound(threadId); + } else { + logger.debug( + `[Codex] Ignoring thread_started for non-active thread; ` + + `eventThreadId=${threadId}, activeThread=${this.currentThreadId}` + ); + } } return; } @@ -405,6 +1527,23 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return; } + if (eventThreadId && this.currentThreadId && eventThreadId !== this.currentThreadId) { + logger.debug( + `[Codex] Routing event from non-active thread into agent trace; ` + + `type=${msgType}, eventThreadId=${eventThreadId}, activeThread=${this.currentThreadId}` + ); + handleChildCodexEvent(eventThreadId, msg); + return; + } + + if (!eventThreadId && this.currentThreadId && isScopeSensitiveCodexEvent(msgType) && hasKnownChildAgents()) { + logger.debug( + `[Codex] Dropping unscoped scope-sensitive event while child agents are active; ` + + `type=${msgType}, activeThread=${this.currentThreadId}` + ); + return; + } + if (msgType === 'task_started') { const turnId = eventTurnId; if (turnId) { @@ -535,6 +1674,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } diffProcessor.reset(); appServerEventConverter.reset(); + mcpTitleByCallId.clear(); + pendingAgentToolInputByCallId.clear(); + childAgentActivityInCurrentTurn = false; wakeLoop(); } @@ -613,8 +1755,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } if (msgType === 'token_count') { + const threadId = eventThreadId ?? this.currentThreadId; session.sendAgentMessage({ - ...msg, + ...addCodexEventScope(msg, 'parent', threadId), + id: randomUUID() + }); + } + if (msgType === 'context_compacted') { + const threadId = eventThreadId ?? this.currentThreadId; + session.sendAgentMessage({ + ...addCodexEventScope({ + type: 'context_compacted', + ...(eventTurnId ? { turn_id: eventTurnId } : {}) + }, 'parent', threadId), id: randomUUID() }); } @@ -695,11 +1848,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { invocation.tool ?? invocation.tool_name ?? msg.tool ); if (callId && name) { + const input = invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {}; + const inputRecord = asRecord(input); + const requestedTitle = inputRecord ? asString(inputRecord.title) : null; + if (isHapiChangeTitleToolName(name) && requestedTitle) { + mcpTitleByCallId.set(callId, requestedTitle); + } session.sendAgentMessage({ type: 'tool-call', name, callId, - input: invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {}, + input, id: randomUUID() }); } @@ -720,6 +1879,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (callId) { + const title = mcpTitleByCallId.get(callId); + mcpTitleByCallId.delete(callId); + if (!isError && title) { + sendTitleSummary(title); + } + session.sendAgentMessage({ type: 'tool-call-result', callId, @@ -729,6 +1894,65 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }); } } + if (msgType === 'codex_tool_call_begin') { + const callId = asString(msg.call_id ?? msg.callId); + const name = asString(msg.name); + if (callId && name) { + if (isCodexAgentToolName(name)) { + const input = msg.input ?? {}; + pendingAgentToolInputByCallId.set(callId, { name, input }); + if (name === 'spawn_agent') { + emitAgentRunStart(callId, input); + } else { + for (const agentId of extractAgentTargets(input)) { + if (!agentCardByAgentId.has(agentId)) { + continue; + } + const activity = name === 'wait_agent' + ? 'Waiting for agent' + : name === 'send_input' + ? 'Sending input' + : name === 'resume_agent' + ? 'Resuming agent' + : name === 'close_agent' + ? 'Closing agent' + : 'Running agent tool'; + emitAgentRunUpdate(agentId, { + status: 'running', + statusText: activity, + activity, + activityKind: name + }); + } + } + return; + } + session.sendAgentMessage({ + type: 'tool-call', + name, + callId, + input: msg.input ?? {}, + id: randomUUID() + }); + } + } + if (msgType === 'codex_tool_call_end') { + const callId = asString(msg.call_id ?? msg.callId); + const name = asString(msg.name) ?? pendingAgentToolInputByCallId.get(callId ?? '')?.name ?? null; + if (callId) { + if (name && isCodexAgentToolName(name)) { + handleAgentToolEnd(callId, name, msg.output, Boolean(msg.is_error ?? msg.isError)); + return; + } + session.sendAgentMessage({ + type: 'tool-call-result', + callId, + output: msg.output, + is_error: Boolean(msg.is_error ?? msg.isError), + id: randomUUID() + }); + } + } if (msgType === 'turn_diff') { const diff = asString(msg.unified_diff); if (diff) { @@ -765,7 +1989,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }); - const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + // In app-server/collab mode, child agents share this MCP bridge. + // If the MCP handler writes the title directly, child title calls + // leak into the parent HAPI session. Defer the side effect until + // parent-thread mcp_tool_call_end reaches this launcher; child + // events are filtered above by thread id. + emitTitleSummary: false + }); this.happyServer = happyServer; this.setupAbortHandlers(session.client.rpcHandlerManager, { @@ -1098,6 +2329,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase { reasoningProcessor.abort(); diffProcessor.reset(); appServerEventConverter.reset(); + mcpTitleByCallId.clear(); + pendingAgentToolInputByCallId.clear(); + pendingAgentTracesByAgentId.clear(); + cancelAllPendingThrottledAgentRunUpdates(); + childAgentRuntimeById.clear(); session.onThinkingChange(false); clearReadyAfterTurnTimer?.(); emitReadyIfIdle({ @@ -1110,6 +2346,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { logActiveHandles('after-turn'); } } + + failPendingAgentStarts('spawn_agent did not return an agent id before the Codex session ended'); + cancelAllPendingThrottledAgentRunUpdates(); } protected async cleanup(): Promise { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index cd8df062..fcb48107 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig'; +import { + buildThreadStartParams, + buildTurnStartParams, + codexCollaborationSpawnAgentInstructions +} from './appServerConfig'; import { codexSystemPrompt } from './systemPrompt'; describe('appServerConfig', () => { const mcpServers = { hapi: { command: 'node', args: ['mcp'] } }; + const withCollaborationInstructions = (developerInstructions: string): string => { + return `${developerInstructions}\n\n${codexCollaborationSpawnAgentInstructions}`; + }; it('applies CLI overrides when permission mode is default', () => { const params = buildThreadStartParams({ @@ -122,7 +129,7 @@ describe('appServerConfig', () => { settings: { model: 'o3', reasoning_effort: 'high', - developer_instructions: codexSystemPrompt + developer_instructions: withCollaborationInstructions(codexSystemPrompt) } }); expect(params.model).toBeUndefined(); @@ -146,7 +153,7 @@ describe('appServerConfig', () => { settings: { model: 'o3', reasoning_effort: 'high', - developer_instructions: codexSystemPrompt + developer_instructions: withCollaborationInstructions(codexSystemPrompt) } }); expect(params.model).toBeUndefined(); @@ -165,11 +172,25 @@ describe('appServerConfig', () => { mode: 'plan', settings: { model: 'o3', - developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.` + developer_instructions: withCollaborationInstructions(`${codexSystemPrompt}\n\nOnly respond in Chinese.`) } }); }); + it('injects spawn_agent argument rules into collaboration mode instructions', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' } + }); + + const instructions = params.collaborationMode?.settings.developer_instructions; + expect(instructions).toContain('If you call spawn_agent with fork_context: true'); + expect(instructions).toContain('do not set agent_type, model, or reasoning_effort'); + expect(instructions).toContain('omit fork_context or set fork_context: false'); + }); + it('rejects collaboration mode payloads without a resolved model', () => { expect(() => buildTurnStartParams({ threadId: 'thread-1', @@ -194,7 +215,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: codexSystemPrompt + developer_instructions: withCollaborationInstructions(codexSystemPrompt) } }); }); @@ -214,7 +235,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: codexSystemPrompt + developer_instructions: withCollaborationInstructions(codexSystemPrompt) } }); }); @@ -233,7 +254,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'gpt-5', - developer_instructions: codexSystemPrompt + developer_instructions: withCollaborationInstructions(codexSystemPrompt) } }); expect(params.model).toBeUndefined(); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 3df7083c..3bad471e 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -11,6 +11,12 @@ import type { } from '../appServerTypes'; import { resolveCodexPermissionModeConfig } from './permissionModeConfig'; +export const codexCollaborationSpawnAgentInstructions = [ + 'Codex sub-agent spawning rules:', + '- If you call spawn_agent with fork_context: true, do not set agent_type, model, or reasoning_effort; full-history forked agents inherit these values from the parent.', + '- If you need a specific agent_type, model, or reasoning_effort, omit fork_context or set fork_context: false, and include only the necessary context in the message.' +].join('\n'); + function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy { return resolveCodexPermissionModeConfig(mode.permissionMode).approvalPolicy; } @@ -63,6 +69,10 @@ function resolveInstructions(args: { }; } +function appendCollaborationInstructions(developerInstructions: string): string { + return `${developerInstructions}\n\n${codexCollaborationSpawnAgentInstructions}`; +} + export function buildThreadStartParams(args: { cwd: string; mode: EnhancedMode; @@ -158,7 +168,7 @@ export function buildTurnStartParams(args: { settings: { model, ...(args.mode?.modelReasoningEffort ? { reasoning_effort: args.mode.modelReasoningEffort } : {}), - developer_instructions: developerInstructions + developer_instructions: appendCollaborationInstructions(developerInstructions) } }; } else if (model) { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 369a9927..ef06cfc8 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -59,6 +59,29 @@ describe('AppServerEventConverter', () => { expect(completed).toEqual([{ type: 'agent_message', message: 'Hello world' }]); }); + it('preserves thread and turn scope on item events', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/agentMessage/delta', { + itemId: 'msg-1', + delta: 'child output', + thread_id: 'child-thread', + turn_id: 'child-turn' + }); + const completed = converter.handleNotification('item/completed', { + item: { id: 'msg-1', type: 'agentMessage' }, + threadId: 'child-thread', + turnId: 'child-turn' + }); + + expect(completed).toEqual([{ + type: 'agent_message', + thread_id: 'child-thread', + turn_id: 'child-turn', + message: 'child output' + }]); + }); + it('deduplicates repeated agent message completions for the same item', () => { const converter = new AppServerEventConverter(); @@ -169,6 +192,134 @@ describe('AppServerEventConverter', () => { }]); }); + it('maps Codex collab spawn agent calls', () => { + const converter = new AppServerEventConverter(); + + const started = converter.handleNotification('item/started', { + item: { + id: 'call-spawn', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + prompt: 'Do side work', + agentType: 'explorer', + forkContext: true, + model: 'gpt-5.5', + reasoningEffort: 'low', + senderThreadId: 'parent-thread', + receiverThreadIds: [] + } + }); + expect(started).toEqual([{ + type: 'codex_tool_call_begin', + call_id: 'call-spawn', + name: 'spawn_agent', + input: { + message: 'Do side work', + agent_type: 'explorer', + fork_context: true, + model: 'gpt-5.5', + reasoning_effort: 'low', + sender_thread_id: 'parent-thread' + } + }]); + + const completed = converter.handleNotification('item/completed', { + item: { + id: 'call-spawn', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'completed', + receiverThreadIds: ['agent-1'], + agentsStates: { + 'agent-1': { status: 'pendingInit', message: null } + } + } + }); + expect(completed).toEqual([{ + type: 'codex_tool_call_end', + call_id: 'call-spawn', + name: 'spawn_agent', + output: { + agent_id: 'agent-1', + agentId: 'agent-1', + status: 'completed', + agentsStates: { + 'agent-1': { status: 'pendingInit', message: null } + } + }, + is_error: false + }]); + }); + + it('maps Codex collab wait and close outputs for web agent views', () => { + const converter = new AppServerEventConverter(); + + const waitStarted = converter.handleNotification('item/started', { + item: { + id: 'call-wait', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['agent-1', 'agent-2'] + } + }); + expect(waitStarted).toEqual([{ + type: 'codex_tool_call_begin', + call_id: 'call-wait', + name: 'wait_agent', + input: { + targets: ['agent-1', 'agent-2'] + } + }]); + + const waitCompleted = converter.handleNotification('item/completed', { + item: { + id: 'call-wait', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'completed', + receiverThreadIds: ['agent-1'], + agentsStates: { + 'agent-1': { status: 'completed', message: '42' } + } + } + }); + expect(waitCompleted).toEqual([{ + type: 'codex_tool_call_end', + call_id: 'call-wait', + name: 'wait_agent', + output: { + status: { + 'agent-1': { completed: '42' } + }, + timed_out: false + }, + is_error: false + }]); + + const closeCompleted = converter.handleNotification('item/completed', { + item: { + id: 'call-close', + type: 'collabAgentToolCall', + tool: 'closeAgent', + status: 'completed', + receiverThreadIds: ['agent-1'], + agentsStates: { + 'agent-1': { status: 'completed', message: 'done' } + } + } + }); + expect(closeCompleted).toEqual([{ + type: 'codex_tool_call_end', + call_id: 'call-close', + name: 'close_agent', + output: { + previous_status: { completed: 'done' }, + agent_id: 'agent-1' + }, + is_error: false + }]); + }); + it('maps reasoning deltas', () => { const converter = new AppServerEventConverter(); @@ -265,6 +416,66 @@ describe('AppServerEventConverter', () => { expect(events).toEqual([{ type: 'turn_diff', unified_diff: 'diff --git a b' }]); }); + it('preserves scope on diff and token usage updates', () => { + const converter = new AppServerEventConverter(); + + const diffEvents = converter.handleNotification('turn/diff/updated', { + threadId: 'child-thread', + turnId: 'child-turn', + diff: 'diff --git a b' + }); + expect(diffEvents).toEqual([{ + type: 'turn_diff', + thread_id: 'child-thread', + turn_id: 'child-turn', + unified_diff: 'diff --git a b' + }]); + + const tokenEvents = converter.handleNotification('thread/tokenUsage/updated', { + tokenUsage: { + thread_id: 'child-thread', + turn_id: 'child-turn', + last_token_usage: { + input_tokens: 10, + output_tokens: 2 + } + } + }); + expect(tokenEvents).toEqual([{ + type: 'token_count', + thread_id: 'child-thread', + turn_id: 'child-turn', + info: { + thread_id: 'child-thread', + turn_id: 'child-turn', + last_token_usage: { + input_tokens: 10, + output_tokens: 2 + } + } + }]); + }); + + it('maps compact notifications with scope', () => { + const converter = new AppServerEventConverter(); + + const direct = converter.handleNotification('thread/compacted', { + thread: { id: 'thread-1' } + }); + expect(direct).toEqual([ + { type: 'thread_compacted', thread_id: 'thread-1' }, + { type: 'context_compacted', thread_id: 'thread-1' } + ]); + + const wrapped = converter.handleNotification('codex/event/context_compacted', { + msg: { type: 'context_compacted', thread_id: 'thread-2', turn_id: 'turn-2' } + }); + expect(wrapped).toEqual([ + { type: 'thread_compacted', thread_id: 'thread-2', turn_id: 'turn-2' }, + { type: 'context_compacted', thread_id: 'thread-2', turn_id: 'turn-2' } + ]); + }); + it('unwraps codex/event task lifecycle', () => { const converter = new AppServerEventConverter(); @@ -279,6 +490,24 @@ describe('AppServerEventConverter', () => { expect(completed).toEqual([{ type: 'task_complete', turn_id: 'turn-1' }]); }); + it('preserves nested scope on wrapped terminal lifecycle events', () => { + const converter = new AppServerEventConverter(); + + const completed = converter.handleNotification('codex/event/task_complete', { + msg: { + type: 'task_complete', + thread: { id: 'child-thread' }, + turn: { id: 'child-turn' } + } + }); + + expect(completed).toEqual([{ + type: 'task_complete', + thread_id: 'child-thread', + turn_id: 'child-turn' + }]); + }); + it('ignores wrapped terminal lifecycle events without turn_id', () => { const converter = new AppServerEventConverter(); @@ -310,6 +539,46 @@ describe('AppServerEventConverter', () => { expect(completed).toEqual([{ type: 'agent_message', message: 'Hello world' }]); }); + it('preserves nested scope on wrapped item lifecycle events', () => { + const converter = new AppServerEventConverter(); + + const started = converter.handleNotification('codex/event/item_started', { + msg: { + type: 'item_started', + thread: { id: 'child-thread' }, + turn: { id: 'child-turn' }, + item: { id: 'cmd-1', type: 'commandExecution', command: 'pwd' } + } + }); + expect(started).toEqual([{ + type: 'exec_command_begin', + thread_id: 'child-thread', + turn_id: 'child-turn', + call_id: 'cmd-1', + command: 'pwd' + }]); + + const completed = converter.handleNotification('codex/event/item_completed', { + msg: { + type: 'item_completed', + item_id: 'msg-1', + item: { + id: 'msg-1', + type: 'AgentMessage', + message: 'child output', + thread: { id: 'child-thread' }, + turn: { id: 'child-turn' } + } + } + }); + expect(completed).toEqual([{ + type: 'agent_message', + thread_id: 'child-thread', + turn_id: 'child-turn', + message: 'child output' + }]); + }); + it('unwraps codex/event reasoning completion from summary text', () => { const converter = new AppServerEventConverter(); @@ -397,11 +666,18 @@ describe('AppServerEventConverter', () => { turnId: 'turn-compact' }); - expect(events).toEqual([{ - type: 'thread_compacted', - thread_id: 'thread-1', - turn_id: 'turn-compact' - }]); + expect(events).toEqual([ + { + type: 'thread_compacted', + thread_id: 'thread-1', + turn_id: 'turn-compact' + }, + { + type: 'context_compacted', + thread_id: 'thread-1', + turn_id: 'turn-compact' + } + ]); }); it('ignores compacted notifications without thread ids', () => { @@ -419,11 +695,18 @@ describe('AppServerEventConverter', () => { msg: { type: 'context_compacted', thread_id: 'thread-1', turn_id: 'turn-compact' } }); - expect(events).toEqual([{ - type: 'thread_compacted', - thread_id: 'thread-1', - turn_id: 'turn-compact' - }]); + expect(events).toEqual([ + { + type: 'thread_compacted', + thread_id: 'thread-1', + turn_id: 'turn-compact' + }, + { + type: 'context_compacted', + thread_id: 'thread-1', + turn_id: 'turn-compact' + } + ]); }); }); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index ecb5a835..13c00be6 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -167,6 +167,188 @@ function extractPlanUpdate(params: Record): ConvertedEvent[] { return plan.length > 0 ? [{ type: 'plan_update', plan }] : []; } +function extractEventScope(params: Record): Record { + const thread = asRecord(params.thread); + const turn = asRecord(params.turn); + const tokenUsage = asRecord(params.tokenUsage ?? params.token_usage ?? params.info); + const tokenUsageThread = asRecord(tokenUsage?.thread); + const tokenUsageTurn = asRecord(tokenUsage?.turn); + const item = asRecord(params.item); + const itemThread = asRecord(item?.thread); + const itemTurn = asRecord(item?.turn); + const threadId = asString( + params.threadId + ?? params.thread_id + ?? thread?.threadId + ?? thread?.thread_id + ?? thread?.id + ?? tokenUsage?.threadId + ?? tokenUsage?.thread_id + ?? tokenUsageThread?.threadId + ?? tokenUsageThread?.thread_id + ?? tokenUsageThread?.id + ?? item?.threadId + ?? item?.thread_id + ?? itemThread?.threadId + ?? itemThread?.thread_id + ?? itemThread?.id + ); + const turnId = asString( + params.turnId + ?? params.turn_id + ?? turn?.turnId + ?? turn?.turn_id + ?? turn?.id + ?? tokenUsage?.turnId + ?? tokenUsage?.turn_id + ?? tokenUsageTurn?.turnId + ?? tokenUsageTurn?.turn_id + ?? tokenUsageTurn?.id + ?? item?.turnId + ?? item?.turn_id + ?? itemTurn?.turnId + ?? itemTurn?.turn_id + ?? itemTurn?.id + ); + + return { + ...(threadId ? { thread_id: threadId } : {}), + ...(turnId ? { turn_id: turnId } : {}) + }; +} + +function addEventScope(events: ConvertedEvent[], scope: Record): ConvertedEvent[] { + if (Object.keys(scope).length === 0) { + return events; + } + + return events.map((event) => ({ + ...scope, + ...event + })); +} + +function normalizeCollabAgentToolName(value: unknown): string | null { + const raw = asString(value); + if (!raw) return null; + + const normalized = raw.trim().toLowerCase().replace(/[\s_-]/g, ''); + if (normalized === 'spawnagent' || normalized === 'spawn') return 'spawn_agent'; + if (normalized === 'sendinput' || normalized === 'sendmessage') return 'send_input'; + if (normalized === 'resumeagent' || normalized === 'resume') return 'resume_agent'; + if (normalized === 'waitagent' || normalized === 'wait') return 'wait_agent'; + if (normalized === 'closeagent' || normalized === 'close') return 'close_agent'; + return null; +} + +function extractStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + : []; +} + +function buildCollabAgentInput(item: Record, toolName: string): Record { + const targets = extractStringArray(item.receiverThreadIds ?? item.receiver_thread_ids ?? item.targets); + const input: Record = {}; + + const prompt = asString(item.prompt ?? item.message); + if (prompt) { + input.message = prompt; + } + + const agentType = asString(item.agentType ?? item.agent_type); + if (agentType) { + input.agent_type = agentType; + } + + const forkContext = asBoolean(item.forkContext ?? item.fork_context); + if (forkContext !== null) { + input.fork_context = forkContext; + } + + const model = asString(item.model); + if (model) { + input.model = model; + } + + const reasoningEffort = asString(item.reasoningEffort ?? item.reasoning_effort); + if (reasoningEffort) { + input.reasoning_effort = reasoningEffort; + } + + const senderThreadId = asString(item.senderThreadId ?? item.sender_thread_id); + if (senderThreadId) { + input.sender_thread_id = senderThreadId; + } + + if (targets.length > 0) { + input.targets = targets; + if (toolName === 'close_agent' || toolName === 'send_input' || toolName === 'resume_agent') { + input.target = targets[0]; + } + } + + return input; +} + +function statusObjectFromAgentState(value: unknown): unknown { + const record = asRecord(value); + if (!record) return value; + + const message = asString(record.message); + const status = asString(record.status ?? record.state); + if (status === 'completed' && message) return { completed: message }; + if ((status === 'failed' || status === 'error') && message) return { failed: message }; + if ((status === 'canceled' || status === 'cancelled') && message) return { canceled: message }; + return value; +} + +function buildCollabAgentOutput(item: Record, toolName: string): Record { + const targets = extractStringArray(item.receiverThreadIds ?? item.receiver_thread_ids ?? item.targets); + const agentsStates = asRecord(item.agentsStates ?? item.agents_states) ?? {}; + const status = asString(item.status); + const error = asString(item.error ?? item.message); + const errorFields = error ? { error, message: error } : {}; + + if (toolName === 'spawn_agent') { + const agentId = targets[0] ?? null; + return { + ...(agentId ? { agent_id: agentId, agentId } : {}), + ...(status ? { status } : {}), + ...errorFields, + agentsStates + }; + } + + if (toolName === 'wait_agent') { + const normalizedStatus: Record = {}; + for (const [agentId, agentStatus] of Object.entries(agentsStates)) { + normalizedStatus[agentId] = statusObjectFromAgentState(agentStatus); + } + return { + status: normalizedStatus, + ...errorFields, + timed_out: status === 'timedOut' || status === 'timed_out' + }; + } + + if (toolName === 'close_agent') { + const firstStatus = targets[0] ? agentsStates[targets[0]] : Object.values(agentsStates)[0]; + return { + previous_status: statusObjectFromAgentState(firstStatus), + ...errorFields, + ...(targets[0] ? { agent_id: targets[0] } : {}) + }; + } + + return { + ...(targets.length > 0 ? { targets } : {}), + ...(status ? { status } : {}), + ...errorFields, + agentsStates + }; +} + export class AppServerEventConverter { private readonly agentMessageBuffers = new Map(); private readonly reasoningBuffers = new Map(); @@ -191,14 +373,19 @@ export class AppServerEventConverter { return []; } + const msgScope = extractEventScope(msg); + if (msgType === 'item_started' || msgType === 'item_completed') { const itemMethod = msgType === 'item_started' ? 'item/started' : 'item/completed'; const item = asRecord(msg.item) ?? {}; + const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id); + const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id); const params: Record = { + ...msgScope, item, itemId: asString(msg.item_id ?? msg.itemId ?? item.id), - threadId: asString(msg.thread_id ?? msg.threadId), - turnId: asString(msg.turn_id ?? msg.turnId) + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}) }; return this.handleNotification(itemMethod, params); } @@ -209,16 +396,20 @@ export class AppServerEventConverter { msgType === 'turn_aborted' || msgType === 'task_failed' ) { - const turnId = asString(msg.turn_id ?? msg.turnId); + const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id); if ((msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') && !turnId) { logger.debug('[AppServerEventConverter] Ignoring wrapped terminal event without turn_id', { msgType }); return []; } - const event: ConvertedEvent = { type: msgType }; + const event: ConvertedEvent = { ...msgScope, type: msgType }; if (turnId) { event.turn_id = turnId; } + const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id); + if (threadId) { + event.thread_id = threadId; + } if (msgType === 'task_failed') { const error = asString(msg.error ?? msg.message ?? asRecord(msg.error)?.message); if (error) { @@ -232,14 +423,14 @@ export class AppServerEventConverter { const itemId = asString(msg.item_id ?? msg.itemId ?? msg.id) ?? 'agent-message'; const delta = asString(msg.delta ?? msg.text ?? msg.message); if (!delta) return []; - return this.handleNotification('item/agentMessage/delta', { itemId, delta }); + return this.handleNotification('item/agentMessage/delta', { itemId, delta, ...msgScope }); } if (msgType === 'reasoning_content_delta') { const itemId = asString(msg.item_id ?? msg.itemId ?? msg.id) ?? 'reasoning'; const delta = asString(msg.delta ?? msg.text ?? msg.message); if (!delta) return []; - return this.handleNotification('item/reasoning/summaryTextDelta', { itemId, delta }); + return this.handleNotification('item/reasoning/summaryTextDelta', { itemId, delta, ...msgScope }); } if (msgType === 'agent_reasoning_section_break') { @@ -247,6 +438,7 @@ export class AppServerEventConverter { const summaryIndex = asNumber(msg.summary_index ?? msg.summaryIndex); return this.handleNotification('item/reasoning/summaryPartAdded', { itemId, + ...msgScope, ...(summaryIndex !== null ? { summaryIndex } : {}) }); } @@ -259,7 +451,7 @@ export class AppServerEventConverter { const itemId = asString(msg.call_id ?? msg.callId ?? msg.item_id ?? msg.itemId ?? msg.id); const delta = asString(msg.delta ?? msg.output ?? msg.stdout ?? msg.text); if (!itemId || !delta) return []; - return this.handleNotification('item/commandExecution/outputDelta', { itemId, delta }); + return this.handleNotification('item/commandExecution/outputDelta', { itemId, delta, ...msgScope }); } if (msgType === 'error') { @@ -269,24 +461,27 @@ export class AppServerEventConverter { return []; } const error = asString(msg.message ?? msg.reason ?? errorRecord?.message); - return error ? [{ type: 'task_failed', error }] : []; + return error ? addEventScope([{ type: 'task_failed', error }], msgScope) : []; } if (msgType === 'plan_update') { - return extractPlanUpdate(msg); + return addEventScope(extractPlanUpdate(msg), msgScope); } if (msgType === 'context_compacted') { - const threadId = asString(msg.thread_id ?? msg.threadId); + const threadId = asString(msg.thread_id ?? msg.threadId ?? msgScope.thread_id); if (!threadId) { return []; } - const turnId = asString(msg.turn_id ?? msg.turnId); - return [{ - type: 'thread_compacted', - thread_id: threadId, - ...(turnId ? { turn_id: turnId } : {}) - }]; + const turnId = asString(msg.turn_id ?? msg.turnId ?? msgScope.turn_id); + return [ + { + type: 'thread_compacted', + thread_id: threadId, + ...(turnId ? { turn_id: turnId } : {}) + }, + ...addEventScope([{ type: 'context_compacted' }], msgScope) + ]; } if ( @@ -301,19 +496,24 @@ export class AppServerEventConverter { return []; } - return [msg as ConvertedEvent]; + return addEventScope([msg as ConvertedEvent], msgScope); } handleNotification(method: string, params: unknown): ConvertedEvent[] { const events: ConvertedEvent[] = []; const paramsRecord = asRecord(params) ?? {}; + const eventScope = extractEventScope(paramsRecord); + const scoped = (event: ConvertedEvent): ConvertedEvent => ({ + ...eventScope, + ...event + }); if (method.startsWith('codex/event/')) { return this.handleWrappedCodexEvent(paramsRecord) ?? events; } if (method === 'turn/plan/updated') { - return extractPlanUpdate(paramsRecord); + return addEventScope(extractPlanUpdate(paramsRecord), eventScope); } if (method === 'account/rateLimits/updated') { @@ -321,16 +521,17 @@ export class AppServerEventConverter { } if (method === 'thread/compacted') { - const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id); + const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id ?? eventScope.thread_id); if (!threadId) { return events; } - const turnId = asString(paramsRecord.turnId ?? paramsRecord.turn_id); + const turnId = asString(paramsRecord.turnId ?? paramsRecord.turn_id ?? eventScope.turn_id); events.push({ type: 'thread_compacted', thread_id: threadId, ...(turnId ? { turn_id: turnId } : {}) }); + events.push(scoped({ type: 'context_compacted' })); return events; } @@ -351,12 +552,12 @@ export class AppServerEventConverter { if (statusType === 'systemError') { const error = asString(status?.message ?? status?.error ?? paramsRecord.message ?? paramsRecord.error) ?? 'Codex thread entered systemError'; - events.push({ + events.push(scoped({ type: 'task_failed', ...(threadId ? { thread_id: threadId } : {}), terminal_source: 'thread_status', error - }); + })); } return events; } @@ -364,7 +565,7 @@ export class AppServerEventConverter { if (method === 'turn/started') { const turn = asRecord(paramsRecord.turn) ?? paramsRecord; const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id); - events.push({ type: 'task_started', ...(turnId ? { turn_id: turnId } : {}) }); + events.push(scoped({ type: 'task_started', ...(turnId ? { turn_id: turnId } : {}) })); return events; } @@ -376,30 +577,30 @@ export class AppServerEventConverter { const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason); if (status === 'interrupted' || status === 'cancelled' || status === 'canceled') { - events.push({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) }); + events.push(scoped({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) })); return events; } if (status === 'failed' || status === 'error') { - events.push({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) }); + events.push(scoped({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) })); return events; } - events.push({ type: 'task_complete', ...(turnId ? { turn_id: turnId } : {}) }); + events.push(scoped({ type: 'task_complete', ...(turnId ? { turn_id: turnId } : {}) })); return events; } if (method === 'turn/diff/updated') { const diff = asString(paramsRecord.diff ?? paramsRecord.unified_diff ?? paramsRecord.unifiedDiff); if (diff) { - events.push({ type: 'turn_diff', unified_diff: diff }); + events.push(scoped({ type: 'turn_diff', unified_diff: diff })); } return events; } if (method === 'thread/tokenUsage/updated') { const info = asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {}; - events.push({ type: 'token_count', info }); + events.push(scoped({ type: 'token_count', info })); return events; } @@ -408,7 +609,7 @@ export class AppServerEventConverter { if (willRetry) return events; const message = asString(paramsRecord.message) ?? asString(asRecord(paramsRecord.error)?.message); if (message) { - events.push({ type: 'task_failed', error: message }); + events.push(scoped({ type: 'task_failed', error: message })); } return events; } @@ -439,7 +640,7 @@ export class AppServerEventConverter { this.lastReasoningDeltaByItemId.set(itemId, delta); const prev = this.reasoningBuffers.get(itemId) ?? ''; this.reasoningBuffers.set(itemId, prev + delta); - events.push({ type: 'agent_reasoning_delta', delta }); + events.push(scoped({ type: 'agent_reasoning_delta', delta })); } return events; } @@ -454,7 +655,7 @@ export class AppServerEventConverter { } this.reasoningSectionBreakKeys.add(key); } - events.push({ type: 'agent_reasoning_section_break' }); + events.push(scoped({ type: 'agent_reasoning_section_break' })); return events; } @@ -491,7 +692,7 @@ export class AppServerEventConverter { } const text = extractItemText(item) ?? this.agentMessageBuffers.get(itemId); if (text) { - events.push({ type: 'agent_message', message: text }); + events.push(scoped({ type: 'agent_message', message: text })); this.completedAgentMessageItems.add(itemId); this.agentMessageBuffers.delete(itemId); } @@ -507,7 +708,7 @@ export class AppServerEventConverter { } const text = extractReasoningText(item) ?? this.reasoningBuffers.get(itemId); if (text) { - events.push({ type: 'agent_reasoning', text }); + events.push(scoped({ type: 'agent_reasoning', text })); this.completedReasoningItems.add(itemId); this.reasoningBuffers.delete(itemId); } @@ -527,11 +728,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.commandMeta.set(itemId, meta); - events.push({ + events.push(scoped({ type: 'exec_command_begin', call_id: itemId, ...meta - }); + })); } if (method === 'item/completed') { @@ -542,7 +743,7 @@ export class AppServerEventConverter { const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode); const status = asString(item.status); - events.push({ + events.push(scoped({ type: 'exec_command_end', call_id: itemId, ...meta, @@ -551,7 +752,7 @@ export class AppServerEventConverter { ...(error ? { error } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), ...(status ? { status } : {}) - }); + })); this.commandMeta.delete(itemId); this.commandOutputBuffers.delete(itemId); @@ -567,7 +768,7 @@ export class AppServerEventConverter { const input = item.arguments ?? item.input ?? {}; if (method === 'item/started') { - events.push({ + events.push(scoped({ type: 'mcp_tool_call_begin', call_id: itemId, server, @@ -577,18 +778,45 @@ export class AppServerEventConverter { tool, arguments: input } - }); + })); } if (method === 'item/completed') { const error = item.error; - events.push({ + events.push(scoped({ type: 'mcp_tool_call_end', call_id: itemId, server, tool, result: error ? { Err: error } : item.result - }); + })); + } + + return events; + } + + if (itemType === 'collabagenttoolcall') { + const toolName = normalizeCollabAgentToolName(item.tool ?? item.name); + if (!toolName) return events; + + if (method === 'item/started') { + events.push(scoped({ + type: 'codex_tool_call_begin', + call_id: itemId, + name: toolName, + input: buildCollabAgentInput(item, toolName) + })); + } + + if (method === 'item/completed') { + const status = asString(item.status); + events.push(scoped({ + type: 'codex_tool_call_end', + call_id: itemId, + name: toolName, + output: buildCollabAgentOutput(item, toolName), + is_error: status === 'failed' || status === 'error' + })); } return events; @@ -603,11 +831,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.fileChangeMeta.set(itemId, meta); - events.push({ + events.push(scoped({ type: 'patch_apply_begin', call_id: itemId, ...meta - }); + })); } if (method === 'item/completed') { @@ -616,14 +844,14 @@ export class AppServerEventConverter { const stderr = asString(item.stderr); const success = asBoolean(item.success ?? item.ok ?? item.applied ?? item.status === 'completed'); - events.push({ + events.push(scoped({ type: 'patch_apply_end', call_id: itemId, ...meta, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}), success: success ?? false - }); + })); this.fileChangeMeta.delete(itemId); } diff --git a/cli/src/codex/utils/appServerPermissionAdapter.test.ts b/cli/src/codex/utils/appServerPermissionAdapter.test.ts index f899111e..18529001 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.test.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.test.ts @@ -77,4 +77,81 @@ describe('registerAppServerPermissionHandlers', () => { decision: 'cancel' }); }); + + it('accepts MCP elicitation requests with schema defaults', async () => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn() + }; + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + await expect(handler?.({ + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'hapi', + mode: 'form', + message: 'Approve MCP tool call?', + _meta: null, + requestedSchema: { + type: 'object', + properties: { + approval: { + type: 'string', + enum: ['allow', 'deny'] + }, + remember: { + type: 'boolean', + default: false + } + }, + required: ['approval', 'remember'] + } + })).resolves.toEqual({ + action: 'accept', + content: { + approval: 'allow', + remember: false + }, + _meta: null + }); + }); + + it('cancels non-HAPI MCP elicitation requests', async () => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn() + }; + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + await expect(handler?.({ + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'external', + mode: 'form', + message: 'Collect data', + _meta: null, + requestedSchema: { + type: 'object', + properties: {}, + } + })).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + }); }); diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index 78fe9f1f..396b8018 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -10,6 +10,14 @@ type PermissionResult = { reason?: string; }; +type ElicitationSchemaProperty = { + type?: unknown; + default?: unknown; + enum?: unknown; + oneOf?: unknown; + items?: unknown; +}; + function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object') { return null; @@ -34,6 +42,86 @@ function mapDecision(decision: PermissionDecision): { decision: string } { } } +function firstString(values: unknown): string | undefined { + if (!Array.isArray(values)) { + return undefined; + } + + return values.find((value): value is string => typeof value === 'string'); +} + +function firstConst(values: unknown): string | undefined { + if (!Array.isArray(values)) { + return undefined; + } + + for (const value of values) { + const record = asRecord(value); + if (typeof record?.const === 'string') { + return record.const; + } + } + + return undefined; +} + +function defaultValueForElicitationProperty(property: ElicitationSchemaProperty): unknown { + if ('default' in property) { + return property.default; + } + + switch (property.type) { + case 'string': + return firstString(property.enum) + ?? firstConst(property.oneOf) + ?? ''; + case 'boolean': + return true; + case 'number': + case 'integer': + return 0; + case 'array': { + const items = asRecord(property.items); + const value = firstString(items?.enum) + ?? firstConst(items?.anyOf); + return value ? [value] : []; + } + default: + return null; + } +} + +function buildAcceptedElicitationContent(params: unknown): Record { + const record = asRecord(params); + const schema = asRecord(record?.requestedSchema); + const properties = asRecord(schema?.properties); + + if (!properties) { + return {}; + } + + const required = Array.isArray(schema?.required) + ? schema.required.filter((value): value is string => typeof value === 'string') + : Object.keys(properties); + const content: Record = {}; + + for (const key of required) { + const property = asRecord(properties[key]); + if (!property) { + continue; + } + + content[key] = defaultValueForElicitationProperty(property); + } + + return content; +} + +function isHapiBridgeElicitation(params: unknown): boolean { + const record = asRecord(params); + return record?.serverName === 'hapi'; +} + export function registerAppServerPermissionHandlers(args: { client: CodexAppServerClient; permissionHandler: CodexPermissionHandler; @@ -102,4 +190,34 @@ export function registerAppServerPermissionHandlers(args: { return result; }); + + client.registerRequestHandler('mcpServer/elicitation/request', async (params) => { + const record = asRecord(params) ?? {}; + + if (!isHapiBridgeElicitation(params)) { + logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', { + serverName: record.serverName, + mode: record.mode, + message: record.message + }); + + return { + action: 'cancel', + content: null, + _meta: null + }; + } + + logger.debug('[CodexAppServer] Accepting MCP elicitation request', { + serverName: record.serverName, + mode: record.mode, + message: record.message + }); + + return { + action: 'accept', + content: buildAcceptedElicitationContent(params), + _meta: null + }; + }); } diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8520ae22..f1f544b2 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -35,6 +35,10 @@ export interface HapiMcpBridge { mcpServers: McpServersConfig; } +export interface HapiMcpBridgeOptions { + emitTitleSummary?: boolean; +} + /** * Start the hapi MCP bridge server and return the configuration * needed to connect Codex to it. @@ -42,8 +46,13 @@ export interface HapiMcpBridge { * This is the single source of truth for MCP bridge setup, * used by both local and remote launchers. */ -export async function buildHapiMcpBridge(client: ApiSessionClient): Promise { - const happyServer = await startHappyServer(client); +export async function buildHapiMcpBridge( + client: ApiSessionClient, + options: HapiMcpBridgeOptions = {} +): Promise { + const happyServer = await startHappyServer(client, { + emitTitleSummary: options.emitTitleSummary + }); const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); return { diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 880bec24..172a2a7d 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -485,4 +485,89 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes Codex scoped snake_case usage fields', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'token_count', + thread_id: 'child-thread', + scope: { role: 'child' }, + info: { + last_token_usage: { + input_tokens: 321, + output_tokens: 12, + cached_input_tokens: 100 + }, + model_context_window: 258_400 + } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'event', + usage: { + input_tokens: 321, + output_tokens: 12, + cache_read_input_tokens: 100, + context_tokens: 321, + context_window: 258400, + thread_id: 'child-thread', + scope_role: 'child' + } + }) + }) + + it('normalizes Codex context_compacted as a compact event', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'context_compacted', + trigger: 'auto', + pre_tokens: 1234 + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + role: 'event', + content: { + type: 'compact', + trigger: 'auto', + preTokens: 1234 + } + }) + }) + + it('normalizes Codex agent-run events for timeline aggregation', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files' }, + status: 'starting' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files' }, + status: 'starting' + } + }) + }) + }) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 2e1fd7c8..6289e986 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -32,18 +32,27 @@ function normalizeAgentEvent(value: unknown): AgentEvent | null { return value as AgentEvent } -function normalizeCodexTokenUsage(value: unknown) { +function normalizeCodexTokenUsage(value: unknown, data?: Record) { const info = isObject(value) ? value : null if (!info) return null + const scope = data && isObject(data.scope) ? data.scope : null // Codex reports both: // - `total`: cumulative usage for the whole session (can be millions). // - `last`: current turn/request usage, which matches the live context bar. // Prefer `last`; falling back to `total` keeps older payloads working. const usageSource = isObject(info.last) ? info.last - : isObject(info.total) - ? info.total - : info + : isObject(info.lastTokenUsage) + ? info.lastTokenUsage + : isObject(info.last_token_usage) + ? info.last_token_usage + : isObject(info.total) + ? info.total + : isObject(info.totalTokenUsage) + ? info.totalTokenUsage + : isObject(info.total_token_usage) + ? info.total_token_usage + : info const inputTokens = asNumber(usageSource.inputTokens ?? usageSource.input_tokens) const outputTokens = asNumber(usageSource.outputTokens ?? usageSource.output_tokens) if (inputTokens === null || outputTokens === null) return null @@ -54,9 +63,23 @@ function normalizeCodexTokenUsage(value: unknown) { // Codex `inputTokens` already includes cached input tokens; expose cache // hits for display, but use `context_tokens` to avoid double-counting. cache_creation_input_tokens: undefined, - cache_read_input_tokens: asNumber(usageSource.cachedInputTokens ?? usageSource.cacheReadInputTokens ?? usageSource.cache_read_input_tokens) ?? undefined, + cache_read_input_tokens: asNumber( + usageSource.cachedInputTokens + ?? usageSource.cached_input_tokens + ?? usageSource.cacheReadInputTokens + ?? usageSource.cache_read_input_tokens + ) ?? undefined, context_tokens: inputTokens, - context_window: asNumber(info.modelContextWindow ?? info.model_context_window) ?? undefined + context_window: asNumber(info.modelContextWindow ?? info.model_context_window) ?? undefined, + thread_id: asString( + data?.thread_id + ?? data?.threadId + ?? scope?.thread_id + ?? scope?.threadId + ?? info.thread_id + ?? info.threadId + ) ?? undefined, + scope_role: asString(data?.scope_role ?? data?.scopeRole ?? scope?.role) ?? undefined } } @@ -422,6 +445,22 @@ export function normalizeAgentRecord( const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null + if ( + data.type === 'agent-run-start' + || data.type === 'agent-run-update' + || data.type === 'agent-run-trace' + ) { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: data as AgentEvent, + isSidechain: false, + meta + } + } + if (data.type === 'message' && typeof data.message === 'string') { return { id: messageId, @@ -446,8 +485,24 @@ export function normalizeAgentRecord( } } + if (data.type === 'context_compacted') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'compact', + trigger: asString(data.trigger) ?? 'auto', + preTokens: asNumber(data.preTokens ?? data.pre_tokens) ?? 0 + }, + isSidechain: false, + meta + } + } + if (data.type === 'token_count') { - const usage = normalizeCodexTokenUsage(data.info) + const usage = normalizeCodexTokenUsage(data.info, data) return usage ? { id: messageId, localId, diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts new file mode 100644 index 00000000..edf3ea7f --- /dev/null +++ b/web/src/chat/reducer.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { reduceChatBlocks } from './reducer' +import type { NormalizedMessage } from './types' + +describe('reduceChatBlocks', () => { + it('ignores child agent usage when calculating parent latest usage', () => { + const messages: NormalizedMessage[] = [ + { + id: 'parent-usage', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { type: 'token-count', info: {} }, + isSidechain: false, + usage: { + input_tokens: 100, + output_tokens: 10, + context_tokens: 100, + scope_role: 'parent' + } + }, + { + id: 'child-usage', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { type: 'token-count', info: {} }, + isSidechain: false, + usage: { + input_tokens: 999, + output_tokens: 1, + context_tokens: 999, + scope_role: 'child' + } + } + ] as NormalizedMessage[] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.latestUsage).toMatchObject({ + inputTokens: 100, + outputTokens: 10, + contextSize: 100 + }) + }) +}) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 0aa278e0..ee727542 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -13,6 +13,10 @@ function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } +function isUsageVisibleInParentContext(usage: UsageData): boolean { + return usage.scope_role !== 'child' +} + export type LatestUsage = { inputTokens: number outputTokens: number @@ -98,7 +102,7 @@ export function reduceChatBlocks( let latestUsage: LatestUsage | null = null for (let i = normalized.length - 1; i >= 0; i--) { const msg = normalized[i] - if (msg.usage) { + if (msg.usage && isUsageVisibleInParentContext(msg.usage)) { latestUsage = { inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens, diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index cbc9df43..291ba0eb 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -439,4 +439,769 @@ describe('reduceTimeline', () => { // mutations land on the rendered block instead of a stale clone. expect(toolBlocksById.get('tc-1')).toBe(toolBlock) }) + + it('aggregates Codex agent-run events into one agent block with child trace', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files', agent_type: 'explorer' }, + status: 'starting', + statusText: 'Starting', + summary: 'Inspect files', + activity: 'Starting task', + activityKind: 'starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-update', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Running', + activity: 'Running command: ls' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-trace-tool', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'tool-call', + name: 'CodexBash', + callId: 'cmd-1', + input: { command: 'ls' } + } + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-trace-result', + localId: null, + createdAt: 1_700_000_003_000, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'tool-call-result', + callId: 'cmd-1', + output: { stdout: 'ok\n', exit_code: 0 }, + is_error: false + } + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-trace-message', + localId: null, + createdAt: 1_700_000_004_000, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'message', + message: 'agent done' + } + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-done', + localId: null, + createdAt: 1_700_000_005_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'completed', + statusText: 'Completed', + activity: 'Completed: agent done', + result: 'agent done' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + + expect(blocks).toHaveLength(1) + const agentBlock = blocks[0] as any + expect(agentBlock.kind).toBe('tool-call') + expect(agentBlock.tool.name).toBe('CodexAgent') + expect(agentBlock.tool.state).toBe('completed') + expect(agentBlock.tool.result).toBe('agent done') + expect(agentBlock.tool.input).toMatchObject({ + agent_type: 'explorer', + agentId: 'agent-1', + statusText: 'Completed', + summary: 'Inspect files', + activity: 'Completed: agent done' + }) + expect(agentBlock.children.some((child: any) => child.kind === 'tool-call' && child.tool.id === 'codex-agent:agent-1:call:cmd-1')).toBe(true) + expect(agentBlock.children.some((child: any) => child.kind === 'agent-text' && child.text === 'agent done')).toBe(true) + }) + + it('keeps new Codex agent trace commands nested under the existing agent block', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files' }, + status: 'starting', + summary: 'Inspect files', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-update', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + activity: 'Running command: ls' + }, + isSidechain: false + } as TracedMessage, + ...['cmd-1', 'cmd-2'].flatMap((callId, index) => ([ + { + id: `${callId}-trace`, + localId: null, + createdAt: 1_700_000_002_000 + index * 2, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'tool-call', + name: 'CodexBash', + callId, + input: { command: callId === 'cmd-1' ? 'ls' : 'pwd' } + } + }, + isSidechain: false + } as TracedMessage, + { + id: `${callId}-result`, + localId: null, + createdAt: 1_700_000_003_000 + index * 2, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'tool-call-result', + callId, + output: { stdout: 'ok\n', exit_code: 0 }, + is_error: false + } + }, + isSidechain: false + } as TracedMessage + ])) + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlock = blocks[0] as any + + expect(blocks).toHaveLength(1) + expect(agentBlock.tool.name).toBe('CodexAgent') + expect(agentBlock.children.filter((child: any) => child.kind === 'tool-call')).toHaveLength(2) + expect(agentBlock.children.map((child: any) => child.kind === 'tool-call' ? child.tool.id : null).filter(Boolean)).toEqual([ + 'codex-agent:agent-1:call:cmd-1', + 'codex-agent:agent-1:call:cmd-2' + ]) + }) + + it('namespaces Codex child trace tool ids away from parent tool ids', () => { + const messages: TracedMessage[] = [ + { + id: 'parent-tool', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ + type: 'tool-call', + id: 'cmd-1', + name: 'CodexBash', + input: { command: 'echo parent' }, + description: null, + uuid: 'parent-tool', + parentUUID: null + }], + isSidechain: false + } as TracedMessage, + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files' }, + status: 'starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-update', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-trace-tool', + localId: null, + createdAt: 1_700_000_003_000, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + message: { + type: 'tool-call', + name: 'CodexBash', + callId: 'cmd-1', + input: { command: 'echo child' } + } + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const parentBlock = blocks.find((block: any) => block.kind === 'tool-call' && block.tool.id === 'cmd-1') as any + const agentBlock = blocks.find((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any + + expect(parentBlock).toBeDefined() + expect(parentBlock.tool.input).toEqual({ command: 'echo parent' }) + expect(agentBlock).toBeDefined() + expect(agentBlock.children.some((child: any) => ( + child.kind === 'tool-call' + && child.tool.id === 'codex-agent:agent-1:call:cmd-1' + && child.tool.input.command === 'echo child' + ))).toBe(true) + }) + + it('merges fallback Codex agent card ids into the spawn card for the same agent', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect README' }, + status: 'starting', + summary: 'Inspect README', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-early-update', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'codex-agent:agent-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Running', + activity: 'Starting task' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-early-trace', + localId: null, + createdAt: 1_700_000_001_500, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'codex-agent:agent-1', + agentId: 'agent-1', + message: { + type: 'tool-call', + name: 'CodexBash', + callId: 'cmd-1', + input: { command: 'pwd' } + } + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-linked-update', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Running', + activity: 'Started' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-late-fallback-update', + localId: null, + createdAt: 1_700_000_003_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'codex-agent:agent-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Waiting for agent', + activity: 'Waiting for agent', + activityKind: 'wait_agent' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks, toolBlocksById } = reduceTimeline(messages, makeContext()) + const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[] + + expect(agentBlocks).toHaveLength(1) + expect(agentBlocks[0].id).toBe('spawn-1') + expect(toolBlocksById.has('spawn-1')).toBe(true) + expect(toolBlocksById.has('codex-agent:agent-1')).toBe(false) + expect(agentBlocks[0].tool.input).toMatchObject({ + agentId: 'agent-1', + summary: 'Inspect README', + activity: 'Waiting for agent', + activityKind: 'wait_agent' + }) + expect(agentBlocks[0].children.some((child: any) => child.kind === 'tool-call' && child.tool.id === 'codex-agent:agent-1:call:cmd-1')).toBe(true) + }) + + it('does not create an orphan Codex agent card for fallback notFound updates', () => { + const messages: TracedMessage[] = [ + { + id: 'stale-agent-wait', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'codex-agent:stale-agent', + agentId: 'stale-agent', + status: 'running', + statusText: 'Waiting for agent', + activity: 'Waiting for agent', + activityKind: 'wait_agent' + }, + isSidechain: false + } as TracedMessage, + { + id: 'stale-agent-not-found', + localId: null, + createdAt: 1_700_000_000_500, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'codex-agent:stale-agent', + agentId: 'stale-agent', + status: 'notFound', + statusText: 'notFound', + activity: 'notFound: {"status":"notFound","message":null}' + }, + isSidechain: false + } as TracedMessage, + { + id: 'new-agent-start', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect README' }, + status: 'starting', + summary: 'Inspect README', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'new-agent-done', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'completed', + statusText: 'Completed', + activity: 'Completed: ok', + result: 'ok' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks, toolBlocksById } = reduceTimeline(messages, makeContext()) + const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[] + + expect(agentBlocks).toHaveLength(1) + expect(agentBlocks[0].id).toBe('spawn-1') + expect(toolBlocksById.has('codex-agent:stale-agent')).toBe(false) + }) + + it('shows notFound as an error when it belongs to a known Codex agent card', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect README' }, + status: 'starting', + summary: 'Inspect README', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-linked', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + activity: 'Started' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-not-found', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'codex-agent:agent-1', + agentId: 'agent-1', + status: 'notFound', + statusText: 'notFound', + activity: 'notFound: {"status":"notFound","message":null}' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlock = blocks[0] as any + + expect(blocks).toHaveLength(1) + expect(agentBlock.id).toBe('spawn-1') + expect(agentBlock.tool.state).toBe('error') + expect(agentBlock.tool.input).toMatchObject({ + agentId: 'agent-1', + agentStatus: 'notFound' + }) + }) + + it('does not regress a completed Codex agent card to running on a later wait_agent begin', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'inspect files' }, + status: 'starting', + summary: 'Inspect files', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-done', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'completed', + statusText: 'Completed', + activity: 'Completed: done', + result: 'done' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-wait-begin', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Waiting for agent', + activity: 'Waiting for agent', + activityKind: 'wait_agent' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlock = blocks[0] as any + + expect(agentBlock.tool.state).toBe('completed') + expect(agentBlock.tool.input).toMatchObject({ + agentStatus: 'completed', + statusText: 'Completed', + activity: 'Completed: done' + }) + }) + + it('keeps Codex agent elapsed time stable when the start event fell out of the visible window', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-update', + localId: null, + createdAt: 1_700_000_010_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + startedAt: 1_700_000_000_000, + status: 'running', + statusText: 'Running command', + activity: 'Running command: test' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-trace', + localId: null, + createdAt: 1_700_000_020_000, + role: 'event', + content: { + type: 'agent-run-trace', + cardId: 'spawn-1', + agentId: 'agent-1', + startedAt: 1_700_000_000_000, + message: { + type: 'message', + message: 'still running' + } + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlock = blocks[0] as any + + expect(agentBlock.tool.name).toBe('CodexAgent') + expect(agentBlock.tool.state).toBe('running') + expect(agentBlock.tool.startedAt).toBe(1_700_000_000_000) + }) + + it('does not turn a completed Codex agent card into an error when close_agent cleans it up', () => { + const messages: TracedMessage[] = [ + { + id: 'agent-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-1', + input: { message: 'review diff' }, + status: 'starting', + summary: 'Review diff', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-done', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'completed', + statusText: 'Completed', + activity: 'Completed: approved', + result: 'approved' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-close-begin', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + statusText: 'Closing agent', + activity: 'Closing agent', + activityKind: 'close_agent' + }, + isSidechain: false + } as TracedMessage, + { + id: 'agent-close-end', + localId: null, + createdAt: 1_700_000_003_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'canceled', + statusText: 'Closed', + activity: 'Closed', + activityKind: 'canceled', + result: { previous_status: { completed: 'approved' }, agent_id: 'agent-1' } + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlock = blocks[0] as any + + expect(agentBlock.tool.state).toBe('completed') + expect(agentBlock.tool.result).toBe('approved') + expect(agentBlock.tool.input).toMatchObject({ + agentStatus: 'completed', + activity: 'Completed: approved' + }) + }) + + it('drops duplicate orphan Codex agent starts with the same work summary', () => { + const messages: TracedMessage[] = [ + { + id: 'orphan-start', + localId: null, + createdAt: 1_700_000_000_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-orphan', + input: { message: 'inspect README' }, + status: 'starting', + summary: 'Inspect README', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'real-start', + localId: null, + createdAt: 1_700_000_001_000, + role: 'event', + content: { + type: 'agent-run-start', + cardId: 'spawn-real', + input: { message: 'inspect README' }, + status: 'starting', + summary: 'Inspect README', + activity: 'Starting' + }, + isSidechain: false + } as TracedMessage, + { + id: 'real-update', + localId: null, + createdAt: 1_700_000_002_000, + role: 'event', + content: { + type: 'agent-run-update', + cardId: 'spawn-real', + agentId: 'agent-real', + status: 'completed', + summary: 'Inspect README', + activity: 'Completed: ok', + result: 'ok' + }, + isSidechain: false + } as TracedMessage + ] + + const { blocks } = reduceTimeline(messages, makeContext()) + const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[] + + expect(agentBlocks).toHaveLength(1) + expect(agentBlocks[0].id).toBe('spawn-real') + expect(agentBlocks[0].tool.input).toMatchObject({ + agentId: 'agent-real', + summary: 'Inspect README', + activity: 'Completed: ok' + }) + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 256d786c..6a372b1f 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -2,8 +2,257 @@ import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, To import type { TracedMessage } from '@/chat/tracer' import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput' import { parseMessageAsEvent } from '@/chat/reducerEvents' -import { ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools' +import { collectTitleChanges, ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools' import { isSubagentToolName } from '@/chat/subagentTool' +import { asString, isObject } from '@hapi/protocol' + +function getEventString(event: Record, key: string): string | null { + return asString(event[key]) +} + +function getEventNumber(event: Record, key: string): number | null { + const value = event[key] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function getAgentRunStartedAt(event: Record): number | null { + return getEventNumber(event, 'startedAt') ?? getEventNumber(event, 'started_at') +} + +function getAgentRunCompletedAt(event: Record): number | null { + return getEventNumber(event, 'completedAt') ?? getEventNumber(event, 'completed_at') +} + +function setEarliestStartedAt(block: ToolCallBlock, startedAt: number | null): void { + if (startedAt === null) return + block.tool.startedAt = block.tool.startedAt === null + ? startedAt + : Math.min(block.tool.startedAt, startedAt) +} + +function getAgentRunCardId(event: Record, fallback: string): string { + return getEventString(event, 'cardId') ?? getEventString(event, 'card_id') ?? fallback +} + +function isFallbackAgentRunCardId(cardId: string, agentId: string | null): boolean { + return agentId !== null && cardId === `codex-agent:${agentId}` +} + +function mapAgentRunStatusToToolState(status: string | null): ToolCallBlock['tool']['state'] { + if (status === 'completed') return 'completed' + if ( + status === 'failed' + || status === 'error' + || status === 'canceled' + || status === 'cancelled' + || status === 'notFound' + || status === 'not_found' + ) return 'error' + if (status === 'pending') return 'pending' + return 'running' +} + +function isTerminalAgentRunState(state: ToolCallBlock['tool']['state']): boolean { + return state === 'completed' || state === 'error' +} + +function isNonTerminalAgentRunState(state: ToolCallBlock['tool']['state']): boolean { + return state === 'running' || state === 'pending' +} + +function shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal( + block: ToolCallBlock, + nextState: ToolCallBlock['tool']['state'], + event: Record +): boolean { + if (!isTerminalAgentRunState(block.tool.state)) return false + if (!isNonTerminalAgentRunState(nextState)) return false + + const activityKind = getEventString(event, 'activityKind') ?? getEventString(event, 'activity_kind') + return activityKind === 'wait_agent' || activityKind === 'close_agent' +} + +function isCloseAgentCleanupUpdate(event: Record): boolean { + const activityKind = getEventString(event, 'activityKind') ?? getEventString(event, 'activity_kind') + if (activityKind === 'close_agent' || activityKind === 'closed') return true + + const activity = getEventString(event, 'activity') + const statusText = getEventString(event, 'statusText') ?? getEventString(event, 'status_text') + if (activityKind !== 'canceled' || (activity !== 'Closed' && statusText !== 'Closed')) return false + + const result = isObject(event.result) ? event.result : null + return Boolean(result && (isObject(result.previous_status) || isObject(result.previousStatus))) +} + +function shouldIgnoreAgentRunCloseCleanupAfterTerminal( + block: ToolCallBlock, + status: string | null, + event: Record +): boolean { + if (!isTerminalAgentRunState(block.tool.state)) return false + if (status === 'failed' || status === 'error') return false + return isCloseAgentCleanupUpdate(event) +} + +function getAgentRunDisplayPatch(event: Record): Record { + const patch: Record = {} + const summary = getEventString(event, 'summary') + const activity = getEventString(event, 'activity') + const activityKind = getEventString(event, 'activityKind') ?? getEventString(event, 'activity_kind') + + if (summary) patch.summary = summary + if (activity) patch.activity = activity + if (activityKind) patch.activityKind = activityKind + + return patch +} + +function getAgentRunFingerprint(event: Record): string | null { + const summary = getEventString(event, 'summary') + if (summary) return summary + + const input = isObject(event.input) ? event.input : null + const direct = input ? asString(input.message) ?? asString(input.prompt) : null + if (direct) return direct.replace(/\s+/g, ' ').trim() + + if (input && Array.isArray(input.items)) { + const text = input.items + .map((item) => isObject(item) ? asString(item.text) : null) + .filter((part): part is string => Boolean(part)) + .join('\n\n') + .replace(/\s+/g, ' ') + .trim() + return text.length > 0 ? text : null + } + + return null +} + +function isAgentNotFoundUpdate(event: Record): boolean { + const status = getEventString(event, 'status') + const activityKind = getEventString(event, 'activityKind') ?? getEventString(event, 'activity_kind') + return status === 'notFound' + || status === 'not_found' + || activityKind === 'not_found' +} + +function isAgentToolOnlyUpdate(event: Record): boolean { + const activityKind = getEventString(event, 'activityKind') ?? getEventString(event, 'activity_kind') + return activityKind === 'wait_agent' + || activityKind === 'send_input' + || activityKind === 'resume_agent' + || activityKind === 'close_agent' + || isAgentNotFoundUpdate(event) +} + +function isOrphanAgentRunBlock(block: ToolCallBlock): boolean { + if (block.children.length > 0) return false + if (block.tool.result !== undefined) return false + if (block.tool.state === 'completed' || block.tool.state === 'error') return false + if (isObject(block.tool.input) && (asString(block.tool.input.agentId) || asString(block.tool.input.agent_id))) { + return false + } + return true +} + +function prefixAgentTraceId(agentId: string, kind: 'trace' | 'call', id: string): string { + const prefix = `codex-agent:${agentId}:` + return id.startsWith(prefix) ? id : `${prefix}${kind}:${id}` +} + +function normalizeTraceMessage( + agentId: string, + message: unknown, + source: TracedMessage, +): TracedMessage[] { + const data = isObject(message) ? message : null + if (!data || typeof data.type !== 'string') return [] + + const traceId = prefixAgentTraceId(agentId, 'trace', asString(data.id) ?? `${source.id}:trace`) + const createdAt = source.createdAt + const base = { + localId: null, + createdAt, + isSidechain: false, + meta: source.meta + } + + if (data.type === 'message' && typeof data.message === 'string') { + return [{ + ...base, + id: traceId, + role: 'agent', + content: [{ type: 'text', text: data.message, uuid: traceId, parentUUID: null }] + } as TracedMessage] + } + + if (data.type === 'reasoning' && typeof data.message === 'string') { + return [{ + ...base, + id: traceId, + role: 'agent', + content: [{ type: 'reasoning', text: data.message, uuid: traceId, parentUUID: null }] + } as TracedMessage] + } + + if (data.type === 'tool-call' && typeof data.callId === 'string') { + const callId = prefixAgentTraceId(agentId, 'call', data.callId) + return [{ + ...base, + id: traceId, + role: 'agent', + content: [{ + type: 'tool-call', + id: callId, + name: asString(data.name) ?? 'unknown', + input: data.input, + description: null, + uuid: traceId, + parentUUID: null + }] + } as TracedMessage] + } + + if (data.type === 'tool-call-result' && typeof data.callId === 'string') { + const callId = prefixAgentTraceId(agentId, 'call', data.callId) + return [{ + ...base, + id: traceId, + role: 'agent', + content: [{ + type: 'tool-result', + tool_use_id: callId, + content: data.output, + is_error: Boolean(data.is_error), + uuid: traceId, + parentUUID: null + }] + } as TracedMessage] + } + + if (data.type === 'token_count') { + return [] + } + + if (data.type === 'ready' || data.type === 'task_complete') { + return [{ + ...base, + id: traceId, + role: 'event', + content: { type: 'ready', agentId } + } as TracedMessage] + } + + return [{ + ...base, + id: traceId, + role: 'event', + content: { + type: 'message', + message: asString(data.statusText) ?? asString(data.status) ?? data.type + } + } as TracedMessage] +} export function reduceTimeline( messages: TracedMessage[], @@ -17,8 +266,150 @@ export function reduceTimeline( ): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { const blocks: ChatBlock[] = [] const toolBlocksById = new Map() + const agentRunBlocksByCardId = new Map() + const agentRunCardByAgentId = new Map() + const agentRunTraceMessagesByCardId = new Map() + const pendingAgentRunCardByFingerprint = new Map() let hasReadyEvent = false + const ensureAgentRunBlock = ( + cardId: string, + seed: { + createdAt: number + invokedAt?: number | null + model?: string | null + localId: string | null + meta?: unknown + input?: unknown + } + ): ToolCallBlock => { + const block = ensureToolBlock(blocks, toolBlocksById, cardId, { + createdAt: seed.createdAt, + invokedAt: seed.invokedAt, + model: seed.model, + localId: seed.localId, + meta: seed.meta, + name: 'CodexAgent', + input: seed.input, + description: null + }) + agentRunBlocksByCardId.set(cardId, block) + return block + } + + const refreshAgentRunChildren = (cardId: string): void => { + const block = agentRunBlocksByCardId.get(cardId) + if (!block) return + const traceMessages = agentRunTraceMessagesByCardId.get(cardId) ?? [] + if (traceMessages.length === 0) { + block.children = [] + return + } + + const child = reduceTimeline(traceMessages, { + permissionsById: context.permissionsById, + groups: new Map(), + consumedGroupIds: new Set(), + titleChangesByToolUseId: collectTitleChanges(traceMessages), + emittedTitleChangeToolUseIds: new Set() + }) + block.children = child.blocks + } + + const patchAgentRunInput = (block: ToolCallBlock, patch: Record): void => { + const current = isObject(block.tool.input) ? block.tool.input : {} + block.tool.input = { + ...current, + ...patch + } + } + + const removeAgentRunBlock = (cardId: string): void => { + const block = agentRunBlocksByCardId.get(cardId) + if (!block) return + const index = blocks.findIndex((candidate) => candidate === block) + if (index !== -1) { + blocks.splice(index, 1) + } + toolBlocksById.delete(cardId) + agentRunBlocksByCardId.delete(cardId) + agentRunTraceMessagesByCardId.delete(cardId) + for (const [fingerprint, pendingCardId] of pendingAgentRunCardByFingerprint) { + if (pendingCardId === cardId) { + pendingAgentRunCardByFingerprint.delete(fingerprint) + } + } + } + + const mergeAgentRunBlock = (fromCardId: string, toCardId: string, toBlock: ToolCallBlock): void => { + if (fromCardId === toCardId) return + + const fromBlock = agentRunBlocksByCardId.get(fromCardId) + if (!fromBlock || fromBlock === toBlock) return + + const fromInput = isObject(fromBlock.tool.input) ? fromBlock.tool.input : {} + const toInput = isObject(toBlock.tool.input) ? toBlock.tool.input : {} + if (Object.keys(fromInput).length > 0 || Object.keys(toInput).length > 0) { + toBlock.tool.input = { + ...fromInput, + ...toInput + } + } + + toBlock.createdAt = Math.min(toBlock.createdAt, fromBlock.createdAt) + toBlock.tool.createdAt = Math.min(toBlock.tool.createdAt, fromBlock.tool.createdAt) + if (fromBlock.tool.startedAt !== null) { + toBlock.tool.startedAt = toBlock.tool.startedAt === null + ? fromBlock.tool.startedAt + : Math.min(toBlock.tool.startedAt, fromBlock.tool.startedAt) + } + if (fromBlock.tool.completedAt !== null) { + toBlock.tool.completedAt = toBlock.tool.completedAt === null + ? fromBlock.tool.completedAt + : Math.max(toBlock.tool.completedAt, fromBlock.tool.completedAt) + } + toBlock.durationMs = toBlock.durationMs ?? fromBlock.durationMs + toBlock.usage = toBlock.usage ?? fromBlock.usage + toBlock.model = toBlock.model ?? fromBlock.model + + if (!isTerminalAgentRunState(toBlock.tool.state) && isTerminalAgentRunState(fromBlock.tool.state)) { + toBlock.tool.state = fromBlock.tool.state + } + if (toBlock.tool.result === undefined && fromBlock.tool.result !== undefined) { + toBlock.tool.result = fromBlock.tool.result + } + + const fromTrace = agentRunTraceMessagesByCardId.get(fromCardId) ?? [] + const toTrace = agentRunTraceMessagesByCardId.get(toCardId) ?? [] + if (fromTrace.length > 0 || toTrace.length > 0) { + const mergedTrace = [...toTrace, ...fromTrace] + .sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)) + agentRunTraceMessagesByCardId.set(toCardId, mergedTrace) + agentRunTraceMessagesByCardId.delete(fromCardId) + refreshAgentRunChildren(toCardId) + } else if (toBlock.children.length === 0 && fromBlock.children.length > 0) { + toBlock.children = fromBlock.children + } + + const index = blocks.findIndex((candidate) => candidate === fromBlock) + if (index !== -1) { + blocks.splice(index, 1) + } + toolBlocksById.delete(fromCardId) + agentRunBlocksByCardId.delete(fromCardId) + + for (const [fingerprint, pendingCardId] of pendingAgentRunCardByFingerprint) { + if (pendingCardId === fromCardId) { + pendingAgentRunCardByFingerprint.set(fingerprint, toCardId) + } + } + for (const [mappedAgentId, mappedCardId] of agentRunCardByAgentId) { + if (mappedCardId === fromCardId) { + agentRunCardByAgentId.set(mappedAgentId, toCardId) + } + } + } + // Pre-scan: collect UUIDs of system-injected user turns (sidechain // prompts, task notifications, system reminders). These are used below // to identify sentinel auto-replies ("No response requested.") whose @@ -70,6 +461,151 @@ export function reduceTimeline( continue } + if ( + msg.content.type === 'agent-run-start' + || msg.content.type === 'agent-run-update' + || msg.content.type === 'agent-run-trace' + ) { + const event = msg.content as Record + const agentId = getEventString(event, 'agentId') ?? getEventString(event, 'agent_id') + const fallbackCardId = agentId ? `codex-agent:${agentId}` : msg.id + const rawCardId = getAgentRunCardId(event, fallbackCardId) + const previousCardId = agentId ? agentRunCardByAgentId.get(agentId) ?? null : null + const previousIsFallback = previousCardId !== null && isFallbackAgentRunCardId(previousCardId, agentId) + const rawIsFallback = isFallbackAgentRunCardId(rawCardId, agentId) + const cardId = agentId && previousCardId && !previousIsFallback && rawIsFallback + ? previousCardId + : rawCardId + const mergeFromCardId = agentId + && previousCardId + && previousCardId !== cardId + && previousIsFallback + && !rawIsFallback + ? previousCardId + : null + const fingerprint = getAgentRunFingerprint(event) + + if ( + msg.content.type === 'agent-run-update' + && agentId + && !previousCardId + && rawIsFallback + && isAgentToolOnlyUpdate(event) + ) { + continue + } + + if (msg.content.type === 'agent-run-start' && !agentId && fingerprint) { + const previousCardId = pendingAgentRunCardByFingerprint.get(fingerprint) + const previousBlock = previousCardId ? agentRunBlocksByCardId.get(previousCardId) : null + if (previousCardId && previousCardId !== cardId && previousBlock && isOrphanAgentRunBlock(previousBlock)) { + removeAgentRunBlock(previousCardId) + } + pendingAgentRunCardByFingerprint.set(fingerprint, cardId) + } + + const block = ensureAgentRunBlock(cardId, { + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + input: event.input + }) + + if (mergeFromCardId) { + mergeAgentRunBlock(mergeFromCardId, cardId, block) + } + if (agentId) { + agentRunCardByAgentId.set(agentId, cardId) + } + + if (msg.content.type === 'agent-run-start') { + const status = getEventString(event, 'status') ?? 'running' + const startedAt = getAgentRunStartedAt(event) ?? msg.createdAt + patchAgentRunInput(block, { + agentId, + agentStatus: status, + statusText: getEventString(event, 'statusText') ?? getEventString(event, 'status_text') ?? 'Starting', + ...getAgentRunDisplayPatch(event) + }) + block.tool.state = mapAgentRunStatusToToolState(status) + if (block.tool.state === 'running') { + setEarliestStartedAt(block, startedAt) + } + continue + } + + if (msg.content.type === 'agent-run-update') { + const status = getEventString(event, 'status') ?? 'running' + const nextState = mapAgentRunStatusToToolState(status) + const startedAt = getAgentRunStartedAt(event) + if ( + shouldIgnoreAgentRunNonTerminalUpdateAfterTerminal(block, nextState, event) + || shouldIgnoreAgentRunCloseCleanupAfterTerminal(block, status, event) + ) { + continue + } + patchAgentRunInput(block, { + agentId, + agentStatus: status, + statusText: getEventString(event, 'statusText') ?? getEventString(event, 'status_text') ?? status, + ...getAgentRunDisplayPatch(event) + }) + block.tool.state = nextState + if (block.tool.state === 'running') { + setEarliestStartedAt(block, startedAt ?? msg.createdAt) + } + if (block.tool.state === 'completed' || block.tool.state === 'error') { + setEarliestStartedAt(block, startedAt) + block.tool.completedAt = getAgentRunCompletedAt(event) ?? msg.createdAt + } + if ('result' in event) { + block.tool.result = event.result + } else if ('error' in event) { + block.tool.result = event.error + } else if ('spawnResult' in event) { + block.tool.result = event.spawnResult + } + continue + } + + if (msg.content.type === 'agent-run-trace') { + const traceAgentId = agentId + if (!traceAgentId) continue + const startedAt = getAgentRunStartedAt(event) + const traceCardId = agentRunCardByAgentId.get(traceAgentId) ?? cardId + const traceBlock = ensureAgentRunBlock(traceCardId, { + createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, + localId: msg.localId, + meta: msg.meta, + input: agentRunBlocksByCardId.has(traceCardId) ? undefined : { agentId: traceAgentId } + }) + const tracePatch: Record = { + agentId: traceAgentId, + agentStatus: traceBlock.tool.state, + ...getAgentRunDisplayPatch(event) + } + if (!isTerminalAgentRunState(traceBlock.tool.state)) { + tracePatch.statusText = getEventString(event, 'statusText') ?? getEventString(event, 'status_text') ?? 'Running' + } + patchAgentRunInput(traceBlock, { + ...tracePatch + }) + const traceMessages = agentRunTraceMessagesByCardId.get(traceCardId) ?? [] + traceMessages.push(...normalizeTraceMessage(traceAgentId, event.message, msg)) + agentRunTraceMessagesByCardId.set(traceCardId, traceMessages) + refreshAgentRunChildren(traceCardId) + if (traceBlock.tool.state !== 'completed' && traceBlock.tool.state !== 'error') { + traceBlock.tool.state = 'running' + setEarliestStartedAt(traceBlock, startedAt ?? msg.createdAt) + } + continue + } + } + blocks.push({ kind: 'agent-event', id: msg.id, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 163d4807..3da7e599 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -7,6 +7,8 @@ export type UsageData = { cache_read_input_tokens?: number context_tokens?: number context_window?: number + thread_id?: string + scope_role?: string service_tier?: string } diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 986498b9..0494bb18 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -543,13 +543,20 @@ export function HappyThread(props: { } const observer = new ResizeObserver(() => { - if (isInitialScrollSettling() && autoScrollEnabledRef.current && !pendingScrollRef.current) { + // Message DOM can grow after messagesVersion commits (assistant-ui + // updates its external runtime in an effect, then markdown/tool + // content may resize). Keep following while the user is at bottom. + if ( + autoScrollEnabledRef.current + && atBottomRef.current + && !pendingScrollRef.current + ) { scrollToBottomInstant() } }) observer.observe(content) return () => observer.disconnect() - }, [isInitialScrollSettling, scrollToBottomInstant]) + }, [scrollToBottomInstant]) useLayoutEffect(() => { const pending = pendingScrollRef.current diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index ee16ab52..5f9e6d05 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -102,6 +102,26 @@ export function HappyAssistantMessage() { ) } + if (toolOnly) { + return ( + + + {showMetadata && ( + + )} + + ) + } + return ( - {block.children.length > 0 ? ( + {!hideChildren && block.children.length > 0 ? ( isTask ? ( <> {taskChildren && taskChildren.pending.length > 0 ? ( @@ -201,6 +202,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { const block = artifact const isTask = isSubagentToolName(block.tool.name) + const hideChildren = block.tool.name === 'CodexAgent' const taskChildren = isTask ? splitTaskChildren(block) : null return ( @@ -213,7 +215,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { onDone={ctx.onRefresh} block={block} /> - {block.children.length > 0 ? ( + {!hideChildren && block.children.length > 0 ? ( isTask ? ( <> {taskChildren && taskChildren.pending.length > 0 ? ( diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 43ec77de..529d346c 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -31,13 +31,14 @@ function ElapsedView(props: { from: number; active: boolean }) { useEffect(() => { if (!props.active) return + setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS) return () => clearInterval(id) - }, [props.active]) + }, [props.active, props.from]) if (!props.active) return null - const elapsed = (now - props.from) / 1000 + const elapsed = Math.max(0, now - props.from) / 1000 if (!Number.isFinite(elapsed)) return null return ( @@ -296,6 +297,7 @@ function ToolCardInner(props: ToolCardProps) { const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) const isQuestionTool = isAskUserQuestion || isRequestUserInput + const isCodexAgentCard = toolName === 'CodexAgent' const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' || ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason)) @@ -306,18 +308,24 @@ function ToolCardInner(props: ToolCardProps) { const header = (
-
+
{presentation.icon}
- + {toolTitle}
{subtitle ? ( - + {truncate(subtitle, 160)} ) : null} diff --git a/web/src/components/ToolCard/codexAgents.ts b/web/src/components/ToolCard/codexAgents.ts new file mode 100644 index 00000000..10e2ae96 --- /dev/null +++ b/web/src/components/ToolCard/codexAgents.ts @@ -0,0 +1,336 @@ +import { isObject, safeStringify } from '@hapi/protocol' +import { getInputStringAny, truncate } from '@/lib/toolInputUtils' + +export const codexAgentToolNames = [ + 'spawn_agent', + 'send_input', + 'resume_agent', + 'wait_agent', + 'close_agent' +] as const + +export type CodexAgentToolName = typeof codexAgentToolNames[number] + +export function isCodexAgentToolName(toolName: string): toolName is CodexAgentToolName { + return (codexAgentToolNames as readonly string[]).includes(toolName) +} + +export function parseMaybeJsonObject(value: unknown): Record | null { + if (isObject(value)) return value + if (typeof value !== 'string') return null + + const trimmed = value.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null + + try { + const parsed = JSON.parse(trimmed) as unknown + return isObject(parsed) ? parsed : null + } catch { + return null + } +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null +} + +function asBooleanLabel(value: unknown): string | null { + return typeof value === 'boolean' ? (value ? 'true' : 'false') : null +} + +function compactText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function cleanAgentPromptForSummary(prompt: string): string { + const withoutTags = prompt + .replace(/<[^>\n]+>/g, ' ') + .replace(/\r/g, '\n') + const noisePatterns = [ + /not alone in the codebase/i, + /do not revert/i, + /don't revert/i, + /list the file paths/i, + /changed files/i, + /final answer/i, + /avoid merge conflicts/i, + /accommodate the changes/i + ] + const lines = withoutTags + .split('\n') + .map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/^#{1,6}\s+/, '')) + .filter((line) => line.length > 0) + .filter((line) => !noisePatterns.some((pattern) => pattern.test(line))) + const candidate = lines.length > 0 ? lines.join(' ') : withoutTags + return compactText(candidate) + .replace(/^(task|your task|request|prompt)\s*[::]\s*/i, '') + .trim() +} + +export function getCodexAgentPrompt(input: unknown): string | null { + const direct = getInputStringAny(input, ['message', 'prompt']) + if (direct) return direct + + if (!isObject(input) || !Array.isArray(input.items)) return null + + const textParts = input.items + .map((item) => isObject(item) && typeof item.text === 'string' ? item.text.trim() : '') + .filter((text) => text.length > 0) + + return textParts.length > 0 ? textParts.join('\n\n') : null +} + +export function summarizeCodexAgentPrompt(prompt: string, maxLength = 80): string | null { + const cleaned = cleanAgentPromptForSummary(prompt) + if (!cleaned) return null + return truncate(cleaned, maxLength) +} + +export function getCodexAgentSummary(input: unknown): string | null { + const explicit = getInputStringAny(input, ['summary', 'title', 'description']) + if (explicit) return truncate(compactText(explicit), 80) + + const prompt = getCodexAgentPrompt(input) + if (prompt) { + const summary = summarizeCodexAgentPrompt(prompt) + if (summary) return summary + } + + const agentType = getCodexAgentType(input) + return agentType ? `${agentType} agent` : null +} + +function getCodexAgentStatus(input: unknown): string | null { + const status = getInputStringAny(input, ['agentStatus', 'status', 'state']) + return status ? status.trim().toLowerCase() : null +} + +function isTerminalCodexAgentStatus(status: string | null): boolean { + return status === 'completed' + || status === 'failed' + || status === 'error' + || status === 'canceled' + || status === 'cancelled' +} + +function normalizeNonTerminalCodexAgentActivity(activity: string): string { + const replacements: Array<[RegExp, string]> = [ + [/^Command completed\b/i, 'Command finished'], + [/^Tool completed\b/i, 'Tool finished'], + [/^Completed\s*:\s*/i, 'Output ready: '], + [/^Completed$/i, 'Running'] + ] + + for (const [pattern, replacement] of replacements) { + if (pattern.test(activity)) { + return activity.replace(pattern, replacement) + } + } + + return activity +} + +export function getCodexAgentActivity(input: unknown): string | null { + const activity = getInputStringAny(input, ['activity', 'statusText', 'status_text', 'agentStatus']) + if (!activity) return null + + const status = getCodexAgentStatus(input) + return isTerminalCodexAgentStatus(status) + ? activity + : normalizeNonTerminalCodexAgentActivity(activity) +} + +export function getCodexAgentReasoningEffort(input: unknown): string | null { + return getInputStringAny(input, ['reasoning_effort', 'reasoningEffort']) +} + +export function formatCodexAgentReasoningEffort(effort: string): string { + const normalized = effort.trim().toLowerCase() + if (!normalized || normalized === 'default') return 'reasoning default' + return `reasoning ${normalized}` +} + +export function getCodexAgentReasoningEffortLabel(input: unknown): string | null { + const effort = getCodexAgentReasoningEffort(input) + return effort ? formatCodexAgentReasoningEffort(effort) : null +} + +export function getCodexAgentType(input: unknown): string | null { + return getInputStringAny(input, ['agent_type', 'subagent_type', 'type']) +} + +export function getCodexAgentTargets(input: unknown): string[] { + if (!isObject(input)) return [] + + const targets = Array.isArray(input.targets) + ? input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : [] + if (targets.length > 0) return targets + + const direct = [input.target, input.id, input.agent_id, input.agentId] + .filter((target): target is string => typeof target === 'string' && target.length > 0) + return direct +} + +export function getCodexAgentFieldRows(toolName: string, input: unknown): Array<{ label: string; value: string }> { + const rows: Array<{ label: string; value: string }> = [] + const agentId = isObject(input) + ? asNonEmptyString(input.agentId) ?? asNonEmptyString(input.agent_id) + : null + if (agentId) rows.push({ label: 'Agent', value: agentId }) + + const statusText = getCodexAgentActivity(input) + if (statusText) rows.push({ label: 'Status', value: statusText }) + + const summary = getCodexAgentSummary(input) + if (summary) rows.push({ label: 'Work', value: summary }) + + const agentType = getCodexAgentType(input) + if (agentType) rows.push({ label: 'Type', value: agentType }) + + const model = getInputStringAny(input, ['model']) + if (model) rows.push({ label: 'Model', value: model }) + + const effort = getCodexAgentReasoningEffort(input) + if (effort) rows.push({ label: 'Reasoning', value: effort }) + + if (isObject(input)) { + const forkContext = asBooleanLabel(input.fork_context) + if (forkContext) rows.push({ label: 'Fork context', value: forkContext }) + + const timeout = typeof input.timeout_ms === 'number' ? `${input.timeout_ms} ms` : null + if (timeout) rows.push({ label: 'Timeout', value: timeout }) + } + + const targets = getCodexAgentTargets(input) + if (targets.length > 0) { + rows.push({ + label: targets.length === 1 ? (toolName === 'resume_agent' ? 'Agent' : 'Target') : 'Targets', + value: targets.join(', ') + }) + } + + return rows +} + +export type CodexSpawnAgentResult = { + agentId: string | null + nickname: string | null +} + +export function parseCodexSpawnAgentResult(result: unknown): CodexSpawnAgentResult | null { + const obj = parseMaybeJsonObject(result) + if (!obj) return null + + const agentId = asNonEmptyString(obj.agent_id) ?? asNonEmptyString(obj.agentId) ?? asNonEmptyString(obj.id) + const nickname = asNonEmptyString(obj.nickname) ?? asNonEmptyString(obj.name) + + if (!agentId && !nickname) return null + return { agentId, nickname } +} + +export type CodexAgentStatus = { + agentId: string + state: string + text: string | null +} + +function extractStatusText(value: unknown): string | null { + if (typeof value === 'string') return value + if (!isObject(value)) return null + + const candidates = ['completed', 'failed', 'error', 'message', 'output', 'text', 'reason'] + for (const key of candidates) { + const candidate = value[key] + if (typeof candidate === 'string') return candidate + } + + return safeStringify(value) +} + +function extractStatusState(value: unknown): string { + if (!isObject(value)) return 'completed' + + const states = ['completed', 'failed', 'error', 'canceled', 'cancelled', 'killed', 'running', 'pending'] + for (const state of states) { + if (state in value) return state === 'cancelled' ? 'canceled' : state + } + + const status = asNonEmptyString(value.status) ?? asNonEmptyString(value.state) + return status ?? 'completed' +} + +export function parseCodexWaitAgentResult(result: unknown): { statuses: CodexAgentStatus[]; timedOut: boolean | null } | null { + const obj = parseMaybeJsonObject(result) + if (!obj) return null + + const statusObj = isObject(obj.status) ? obj.status : null + const statuses: CodexAgentStatus[] = [] + + if (statusObj) { + for (const [agentId, statusValue] of Object.entries(statusObj)) { + statuses.push({ + agentId, + state: extractStatusState(statusValue), + text: extractStatusText(statusValue) + }) + } + } + + const timedOut = typeof obj.timed_out === 'boolean' + ? obj.timed_out + : typeof obj.timedOut === 'boolean' + ? obj.timedOut + : null + + if (statuses.length === 0 && timedOut === null) return null + return { statuses, timedOut } +} + +export function parseCodexCloseAgentResult(result: unknown): CodexAgentStatus | null { + const obj = parseMaybeJsonObject(result) + if (!obj) return null + + const previousStatus = isObject(obj.previous_status) ? obj.previous_status + : isObject(obj.previousStatus) ? obj.previousStatus + : null + if (!previousStatus) return null + + return { + agentId: '', + state: extractStatusState(previousStatus), + text: extractStatusText(previousStatus) + } +} + +export function summarizeCodexAgentResult(toolName: string, result: unknown): string | null { + if (toolName === 'spawn_agent') { + const parsed = parseCodexSpawnAgentResult(result) + if (!parsed) return null + const label = parsed.nickname && parsed.agentId + ? `${parsed.nickname} (${parsed.agentId})` + : parsed.nickname ?? parsed.agentId + return label ? `Launched ${label}` : 'Agent launched' + } + + if (toolName === 'wait_agent') { + const parsed = parseCodexWaitAgentResult(result) + if (!parsed) return null + if (parsed.statuses.length === 0 && parsed.timedOut) return 'Timed out' + const completed = parsed.statuses.filter((status) => status.state === 'completed').length + const failed = parsed.statuses.filter((status) => status.state !== 'completed').length + const parts = [] + if (completed > 0) parts.push(`${completed} completed`) + if (failed > 0) parts.push(`${failed} non-completed`) + if (parsed.timedOut) parts.push('timed out') + return parts.length > 0 ? parts.join(', ') : 'No agent status yet' + } + + if (toolName === 'close_agent') { + const parsed = parseCodexCloseAgentResult(result) + if (!parsed) return null + return `Closed (${parsed.state})` + } + + return null +} diff --git a/web/src/components/ToolCard/knownTools.test.tsx b/web/src/components/ToolCard/knownTools.test.tsx index a7d22f84..0be5ffe1 100644 --- a/web/src/components/ToolCard/knownTools.test.tsx +++ b/web/src/components/ToolCard/knownTools.test.tsx @@ -72,3 +72,132 @@ describe('getToolPresentation — unknown tool semantic title + subtitle dedup', expect(presentation.subtitle).toBeNull() }) }) + +describe('getToolPresentation — Codex agent tools', () => { + it('titles CodexAgent cards from work summary instead of agent id', () => { + const presentation = getToolPresentation({ + toolName: 'CodexAgent', + input: { + agentId: 'agent-1234567890', + summary: '检查 Hub Web README', + activity: 'Reading file: README.md', + reasoning_effort: 'medium' + }, + result: null, + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Agent: 检查 Hub Web README') + expect(presentation.title).not.toContain('agent-1234567890') + expect(presentation.subtitle).toBe('reasoning medium · Reading file: README.md') + expect(presentation.minimal).toBe(true) + }) + + it('shows Codex auto-selected effort on CodexAgent cards even before activity is available', () => { + const presentation = getToolPresentation({ + toolName: 'CodexAgent', + input: { + summary: 'Inspect package metadata', + reasoning_effort: 'low' + }, + result: null, + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Agent: Inspect package metadata') + expect(presentation.subtitle).toBe('reasoning low') + }) + + it('does not present sub-operation completion as final agent completion while still running', () => { + const presentation = getToolPresentation({ + toolName: 'CodexAgent', + input: { + summary: 'Inspect package metadata', + agentStatus: 'running', + activity: 'Command completed: bun test', + reasoning_effort: 'low' + }, + result: null, + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.subtitle).toBe('reasoning low · Command finished: bun test') + }) + + it('falls back to prompt-derived CodexAgent titles without exposing agent id', () => { + const presentation = getToolPresentation({ + toolName: 'CodexAgent', + input: { + agentId: 'agent-1234567890', + message: 'Fix the reducer for live agent cards.\nDo not revert other changes.' + }, + result: null, + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Agent: Fix the reducer for live agent cards.') + expect(presentation.title).not.toContain('agent-1234567890') + }) + + it('summarizes spawn_agent with the spawned agent id', () => { + const presentation = getToolPresentation({ + toolName: 'spawn_agent', + input: { + agent_type: 'worker', + message: 'Implement the parser' + }, + result: '{"agent_id":"agent-123","nickname":"Raman"}', + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Spawn worker agent') + expect(presentation.subtitle).toBe('Launched Raman (agent-123)') + expect(presentation.minimal).toBe(true) + }) + + it('summarizes wait_agent status counts', () => { + const presentation = getToolPresentation({ + toolName: 'wait_agent', + input: { + targets: ['a', 'b'], + timeout_ms: 30000 + }, + result: '{"status":{"a":{"completed":"done"},"b":{"failed":"boom"}},"timed_out":false}', + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Wait for 2 agents') + expect(presentation.subtitle).toBe('1 completed, 1 non-completed') + expect(presentation.minimal).toBe(true) + }) + + it('does not expose close_agent previous output in the collapsed subtitle', () => { + const presentation = getToolPresentation({ + toolName: 'close_agent', + input: { + target: 'agent-123' + }, + result: '{"previous_status":{"completed":"hidden child output"}}', + childrenCount: 0, + description: null, + metadata: null, + }) + + expect(presentation.title).toBe('Close agent') + expect(presentation.subtitle).toBe('Closed (completed)') + expect(presentation.subtitle).not.toContain('hidden child output') + expect(presentation.minimal).toBe(true) + }) +}) diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index f7aaa546..4ab1f730 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -6,6 +6,15 @@ import type { ChecklistItem } from '@/components/ToolCard/checklist' import { extractTodoChecklist, extractUpdatePlanChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' +import { + getCodexAgentActivity, + getCodexAgentPrompt, + getCodexAgentReasoningEffortLabel, + getCodexAgentSummary, + getCodexAgentTargets, + getCodexAgentType, + summarizeCodexAgentResult +} from '@/components/ToolCard/codexAgents' const DEFAULT_ICON_CLASS = 'h-3.5 w-3.5' // Tool presentation registry for `hapi/web` (aligned with `hapi-app`). @@ -175,6 +184,24 @@ export const knownTools: Record getInputStringAny(opts.input, ['message', 'command']) ?? null, minimal: true }, + CodexAgent: { + icon: () => , + title: (opts) => { + const summary = getCodexAgentSummary(opts.input) + if (summary) return `Agent: ${summary}` + return 'Agent' + }, + subtitle: (opts) => { + const activity = getCodexAgentActivity(opts.input) + const result = summarizeCodexAgentResult('wait_agent', opts.result) + const prompt = getCodexAgentPrompt(opts.input) + const status = activity ?? result ?? (prompt ? truncate(prompt, 120) : null) + const effort = getCodexAgentReasoningEffortLabel(opts.input) + if (effort && status) return `${effort} · ${status}` + return effort ?? status + }, + minimal: true + }, shell_command: { icon: () => , title: (opts) => opts.description ?? 'Terminal', @@ -305,6 +332,63 @@ export const knownTools: Record opts.childrenCount === 0 }, + spawn_agent: { + icon: () => , + title: (opts) => { + const agentType = getCodexAgentType(opts.input) + return agentType ? `Spawn ${agentType} agent` : 'Spawn agent' + }, + subtitle: (opts) => { + const summary = summarizeCodexAgentResult(opts.toolName, opts.result) + if (summary) return summary + const agentType = getCodexAgentType(opts.input) + return agentType ? `${agentType} agent` : 'Background agent' + }, + minimal: true + }, + send_input: { + icon: () => , + title: () => 'Message agent', + subtitle: (opts) => { + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : 'Background message' + }, + minimal: true + }, + resume_agent: { + icon: () => , + title: () => 'Resume agent', + subtitle: (opts) => { + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : null + }, + minimal: true + }, + wait_agent: { + icon: () => , + title: (opts) => { + const targets = getCodexAgentTargets(opts.input) + return targets.length > 1 ? `Wait for ${targets.length} agents` : 'Wait for agent' + }, + subtitle: (opts) => { + const summary = summarizeCodexAgentResult(opts.toolName, opts.result) + if (summary) return summary + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : null + }, + minimal: true + }, + close_agent: { + icon: () => , + title: () => 'Close agent', + subtitle: (opts) => { + const summary = summarizeCodexAgentResult(opts.toolName, opts.result) + if (summary) return summary + const targets = getCodexAgentTargets(opts.input) + return targets.length > 0 ? targets.join(', ') : null + }, + minimal: true + }, CodexReasoning: { icon: () => , title: (opts) => getInputStringAny(opts.input, ['title']) ?? 'Reasoning', diff --git a/web/src/components/ToolCard/trace.test.tsx b/web/src/components/ToolCard/trace.test.tsx index a9928849..8edf6820 100644 --- a/web/src/components/ToolCard/trace.test.tsx +++ b/web/src/components/ToolCard/trace.test.tsx @@ -95,6 +95,31 @@ function makeTaskBlock( } } +function makeCodexAgentBlock( + children: ToolCallBlock[], + state: ToolCallBlock['tool']['state'] = 'completed', + result: unknown = 'done', +): ToolCallBlock { + return { + kind: 'tool-call', + id: 'agent-1', + localId: null, + createdAt: 1000, + tool: { + id: 'agent-1', + name: 'CodexAgent', + state, + input: { message: 'inspect repo', agentId: 'subagent-1' }, + createdAt: 1000, + startedAt: 1000, + completedAt: 2000, + description: null, + result, + }, + children, + } +} + function makeAgentBlock( children: ToolCallBlock[], state: ToolCallBlock['tool']['state'] = 'completed', @@ -309,6 +334,26 @@ describe('TraceSection', () => { expect(screen.getByText('Result')).toBeInTheDocument() }) + it('opens CodexAgent trace by default but leaves child rows collapsed', () => { + const block = makeCodexAgentBlock([makeChild('c1', 'Bash'), makeChild('c2', 'Read')], 'completed') + const { container } = render() + + expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull() + expect(container.querySelector('.border-l')).toBeNull() + expect(container.textContent).toContain('Terminal') + expect(container.textContent).toContain('file-c2.ts') + expect(container.textContent).not.toContain('Input') + expect(container.textContent).not.toContain('Result') + + const childButtons = Array.from(container.querySelectorAll('button')) + .filter((button) => button.getAttribute('aria-expanded') === null) + expect(childButtons).toHaveLength(2) + + fireEvent.click(childButtons[0]) + expect(container.textContent).toContain('Input') + expect(container.textContent).toContain('Result') + }) + // Agent tool name — same Trace UX as Task it('renders Trace header for Agent blocks', () => { const block = makeAgentBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')]) diff --git a/web/src/components/ToolCard/trace.tsx b/web/src/components/ToolCard/trace.tsx index 54fd6639..910ed28e 100644 --- a/web/src/components/ToolCard/trace.tsx +++ b/web/src/components/ToolCard/trace.tsx @@ -4,12 +4,14 @@ */ import { useState } from 'react' import { isObject, safeStringify } from '@hapi/protocol' -import type { ToolCallBlock } from '@/chat/types' +import type { ChatBlock, ToolCallBlock } from '@/chat/types' import type { SessionMetadataSummary } from '@/types/api' import { getToolFullViewComponent } from '@/components/ToolCard/views/_all' import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers' import { CodeBlock } from '@/components/CodeBlock' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { getEventPresentation } from '@/chat/presentation' import { useTranslation } from '@/lib/use-translation' import { isSubagentToolName } from '@/chat/subagentTool' @@ -57,6 +59,13 @@ export function getTaskTraceChildren(block: ToolCallBlock): ToolCallBlock[] | nu return children.length === 0 ? null : children } +function getTraceChildren(block: ToolCallBlock): ChatBlock[] | null { + if (block.tool.name === 'CodexAgent') { + return block.children.length === 0 ? null : block.children + } + return getTaskTraceChildren(block) +} + /** * Formats the summary line shown in the Trace header. * Falls back gracefully when token / duration data is unavailable. @@ -93,11 +102,14 @@ type TraceSectionProps = { export function TraceSection({ block, metadata }: TraceSectionProps) { const { t } = useTranslation() - const children = getTaskTraceChildren(block) + const children = getTraceChildren(block) if (!children) return null const state = block.tool.state - const defaultOpen = state === 'running' || state === 'error' || state === 'pending' + const isCodexAgentTrace = block.tool.name === 'CodexAgent' + const defaultOpen = isCodexAgentTrace || state === 'running' || state === 'error' || state === 'pending' + const fixedHeight = isCodexAgentTrace + const mode = isCodexAgentTrace ? 'session' : 'trace' // Extract summary metadata from result using typed helper const { totalTokens, totalDurationMs, totalToolUseCount } = readSummaryFields(block.tool.result) @@ -111,6 +123,8 @@ export function TraceSection({ block, metadata }: TraceSectionProps) { metadata={metadata} defaultOpen={defaultOpen} summaryText={summaryText} + fixedHeight={fixedHeight} + mode={mode} /> ) } @@ -120,10 +134,12 @@ export function TraceSection({ block, metadata }: TraceSectionProps) { // --------------------------------------------------------------------------- type TraceSectionInnerProps = { - items: ToolCallBlock[] + items: ChatBlock[] metadata: SessionMetadataSummary | null defaultOpen: boolean summaryText: string + fixedHeight: boolean + mode: 'trace' | 'session' } function TraceSectionInner({ @@ -131,6 +147,8 @@ function TraceSectionInner({ metadata, defaultOpen, summaryText, + fixedHeight, + mode, }: TraceSectionInnerProps) { const { t } = useTranslation() const [open, setOpen] = useState(defaultOpen) @@ -149,9 +167,11 @@ function TraceSectionInner({ ({summaryText}) - {open && ( - - )} + {open ? ( +
+ +
+ ) : null}
) } @@ -161,24 +181,27 @@ function TraceSectionInner({ // --------------------------------------------------------------------------- type TraceChildListProps = { - items: ToolCallBlock[] + items: ChatBlock[] metadata: SessionMetadataSummary | null + mode: 'trace' | 'session' } -function TraceChildList({ items, metadata }: TraceChildListProps) { +function TraceChildList({ items, metadata, mode }: TraceChildListProps) { const [expandedId, setExpandedId] = useState(null) return ( -
+
{items.map((child) => ( - setExpandedId((prev) => (prev === child.id ? null : child.id)) - } + onToggle={() => setExpandedId((prev) => (prev === child.id ? null : child.id))} + mode={mode} /> ))}
@@ -190,34 +213,115 @@ function TraceChildList({ items, metadata }: TraceChildListProps) { // --------------------------------------------------------------------------- type TraceChildRowProps = { - child: ToolCallBlock + child: ChatBlock metadata: SessionMetadataSummary | null expanded: boolean - onToggle: () => void + onToggle?: () => void + mode: 'trace' | 'session' } -function TraceChildRow({ child, metadata, expanded, onToggle }: TraceChildRowProps) { +function TraceChildRow({ child, metadata, expanded, onToggle, mode }: TraceChildRowProps) { const { t } = useTranslation() + const isSessionMode = mode === 'session' + const rowClassName = isSessionMode + ? 'flex flex-col gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-2' + : 'flex flex-col gap-1' + const detailClassName = isSessionMode + ? 'rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm' + : 'ml-8 rounded border border-[var(--app-border)] p-2 text-sm' + const detailPlainClassName = isSessionMode ? '' : 'ml-8' + const chevron = onToggle ? ( + {expanded ? '▾' : '▸'} + ) : ( + + ) + + if (child.kind === 'agent-text' || child.kind === 'agent-reasoning') { + const label = child.kind === 'agent-reasoning' ? 'Reasoning' : 'Message' + const preview = child.text.trim().split('\n')[0] ?? '' + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ) + } + + if (child.kind === 'cli-output') { + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ) + } + + if (child.kind === 'agent-event') { + const presentation = getEventPresentation(child.event) + return ( +
+ + {presentation.icon ? : null} + {presentation.text} +
+ ) + } + + if (child.kind !== 'tool-call') { + return null + } + const label = formatTaskChildLabel(child, metadata, t) const FullInputView = getToolFullViewComponent(child.tool.name) const ResultView = getToolResultViewComponent(child.tool.name) return ( -
+
{expanded && ( -
+
{t('tool.input')}
{FullInputView ? ( diff --git a/web/src/components/ToolCard/views/_all.tsx b/web/src/components/ToolCard/views/_all.tsx index ed8c2148..8730728f 100644 --- a/web/src/components/ToolCard/views/_all.tsx +++ b/web/src/components/ToolCard/views/_all.tsx @@ -12,6 +12,11 @@ import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView' import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView' import { WriteView } from '@/components/ToolCard/views/WriteView' import { getInputStringAny } from '@/lib/toolInputUtils' +import { + getCodexAgentFieldRows, + getCodexAgentPrompt, + summarizeCodexAgentResult +} from '@/components/ToolCard/codexAgents' export type ToolViewProps = { block: ToolCallBlock @@ -30,6 +35,44 @@ const SkillFullView: ToolViewComponent = ({ block }: ToolViewProps) => { ) } +const CodexAgentView: ToolViewComponent = ({ block, surface }: ToolViewProps) => { + const input = block.tool.input + const rows = getCodexAgentFieldRows(block.tool.name, input) + const prompt = getCodexAgentPrompt(input) + const resultSummary = surface === 'inline' + ? summarizeCodexAgentResult(block.tool.name, block.tool.result) + : null + + return ( +
+ {surface === 'dialog' && prompt ? ( +
+
+ Prompt +
+
{prompt}
+
+ ) : null} + {rows.length > 0 ? ( +
+ {rows.map((row) => ( + + {row.label}: + {row.value} + + ))} +
+ ) : null} + {resultSummary ? ( +
{resultSummary}
+ ) : null} +
+ ) +} + export const toolViewRegistry: Record = { Edit: EditView, MultiEdit: MultiEditView, @@ -37,6 +80,12 @@ export const toolViewRegistry: Record = { TodoWrite: TodoWriteView, update_plan: UpdatePlanView, CodexDiff: CodexDiffCompactView, + CodexAgent: CodexAgentView, + spawn_agent: CodexAgentView, + send_input: CodexAgentView, + resume_agent: CodexAgentView, + wait_agent: CodexAgentView, + close_agent: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, ask_user_question: AskUserQuestionView, @@ -50,7 +99,13 @@ export const toolFullViewRegistry: Record = { Write: WriteView, CodexDiff: CodexDiffFullView, CodexPatch: CodexPatchView, + CodexAgent: CodexAgentView, Skill: SkillFullView, + spawn_agent: CodexAgentView, + send_input: CodexAgentView, + resume_agent: CodexAgentView, + wait_agent: CodexAgentView, + close_agent: CodexAgentView, AskUserQuestion: AskUserQuestionView, ExitPlanMode: ExitPlanModeView, ask_user_question: AskUserQuestionView, diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index d2259cb5..37065349 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -10,6 +10,17 @@ vi.mock('@/components/MarkdownRenderer', () => ({ ) })) +vi.mock('@/components/CodeBlock', () => ({ + CodeBlock: (props: { code: string; language?: string; title?: string; className?: string }) => ( +
+ {props.title ?
{props.title}
: null} +
+                {props.code}
+            
+
+ ) +})) + describe('extractTextFromResult', () => { it('returns string directly', () => { expect(extractTextFromResult('hello')).toBe('hello') @@ -139,6 +150,11 @@ describe('getToolResultViewComponent registry', () => { expect(getToolResultViewComponent('CodexBash')).not.toBe(getToolResultViewComponent('SomeUnknownTool')) }) + it('uses a dedicated result view for Codex agent tools', () => { + expect(getToolResultViewComponent('spawn_agent')).not.toBe(getToolResultViewComponent('SomeUnknownTool')) + expect(getToolResultViewComponent('wait_agent')).toBe(getToolResultViewComponent('spawn_agent')) + }) + it('Agent falls back to GenericResultView (no dedicated view — view layer must not filter content)', () => { const agentView = getToolResultViewComponent('Agent') const genericView = getToolResultViewComponent('SomeUnknownTool') @@ -199,6 +215,138 @@ describe('dialog result formatting', () => { }) }) +describe('Codex agent result formatting', () => { + function renderToolResult( + toolName: string, + result: unknown, + input: unknown = {}, + surface: 'inline' | 'dialog' = 'dialog' + ) { + const ResultView = getToolResultViewComponent(toolName) + const block: ToolCallBlock = { + id: 'tool-agent', + localId: null, + createdAt: 0, + kind: 'tool-call', + children: [], + tool: { + id: 'tool-agent', + name: toolName, + state: 'completed', + input, + result, + createdAt: 0, + startedAt: null, + completedAt: 0, + description: null + } + } + + return render( + + + + ) + } + + it('renders spawn_agent JSON output as launch metadata', () => { + const { container } = renderToolResult( + 'spawn_agent', + '{"agent_id":"agent-123","nickname":"Singer"}' + ) + + expect(container).toHaveTextContent('Agent launched') + expect(container).toHaveTextContent('Singer') + expect(container).toHaveTextContent('agent-123') + }) + + it('renders wait_agent completion output per agent', () => { + const { container } = renderToolResult( + 'wait_agent', + '{"status":{"agent-123":{"completed":"42。"}},"timed_out":false}', + { targets: ['agent-123'] } + ) + + expect(container).toHaveTextContent('1 agent') + expect(container).toHaveTextContent('completed') + expect(container).toHaveTextContent('agent-123') + expect(container).toHaveTextContent('42。') + }) + + it('hides wait_agent completion text inline', () => { + const { container } = renderToolResult( + 'wait_agent', + '{"status":{"agent-123":{"completed":"secret child output"}},"timed_out":false}', + { targets: ['agent-123'] }, + 'inline' + ) + + expect(container).toHaveTextContent('1 agent') + expect(container).toHaveTextContent('1 completed') + expect(container).not.toHaveTextContent('agent-123') + expect(container).not.toHaveTextContent('secret child output') + }) + + it('renders close_agent previous status', () => { + const { container } = renderToolResult( + 'close_agent', + '{"previous_status":{"completed":"done"}}', + { target: 'agent-123' } + ) + + expect(container).toHaveTextContent('Agent closed') + expect(container).toHaveTextContent('agent-123') + expect(container).toHaveTextContent('done') + }) + + it('hides close_agent previous status text inline', () => { + const { container } = renderToolResult( + 'close_agent', + '{"previous_status":{"completed":"secret close output"}}', + { target: 'agent-123' }, + 'inline' + ) + + expect(container).toHaveTextContent('Agent closed') + expect(container).toHaveTextContent('agent-123') + expect(container).not.toHaveTextContent('secret close output') + }) + + it('renders CodexAgent live activity while running without a result', () => { + const ResultView = getToolResultViewComponent('CodexAgent') + const block: ToolCallBlock = { + id: 'tool-agent', + localId: null, + createdAt: 0, + kind: 'tool-call', + children: [], + tool: { + id: 'tool-agent', + name: 'CodexAgent', + state: 'running', + input: { + summary: 'Inspect README', + activity: 'Reading file: README.md' + }, + result: null, + createdAt: 0, + startedAt: 0, + completedAt: null, + description: null + } + } + + const { container } = render( + + + + ) + + expect(container).toHaveTextContent('Reading file: README.md') + expect(container).not.toHaveTextContent('Running…') + }) +}) + describe('read file result formatting', () => { function renderToolResult(toolName: string, result: unknown, input: unknown = {}) { const ResultView = getToolResultViewComponent(toolName) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index a3148252..a78fcbc0 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -6,6 +6,13 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { ChecklistList, extractTodoChecklist } from '@/components/ToolCard/checklist' import { basename, resolveDisplayPath } from '@/utils/path' import { getInputStringAny } from '@/lib/toolInputUtils' +import { + getCodexAgentActivity, + getCodexAgentTargets, + parseCodexCloseAgentResult, + parseCodexSpawnAgentResult, + parseCodexWaitAgentResult +} from '@/components/ToolCard/codexAgents' function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { const regex = /(.*?)<\/tool_use_error>/s @@ -751,6 +758,127 @@ const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => { return } +function AgentIdPill(props: { label: string; value: string }) { + return ( + + {props.label}: + {props.value} + + ) +} + +const CodexAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const { name, state, result, input } = props.block.tool + const showDetails = props.surface === 'dialog' + + if (result === undefined || result === null) { + return + } + + if (state === 'error') { + const text = extractTextFromResult(result) + return ( +
+ {text?.trim() ? text : 'Agent tool failed'} +
+ ) + } + + if (name === 'spawn_agent') { + const parsed = parseCodexSpawnAgentResult(result) + if (parsed) { + return ( +
+ + {parsed.nickname ? : null} + {parsed.agentId ? : null} + {showDetails ? : null} +
+ ) + } + } + + if (name === 'wait_agent') { + const parsed = parseCodexWaitAgentResult(result) + if (parsed) { + if (parsed.statuses.length === 0) { + return + } + + return ( +
+
+ {parsed.timedOut ? : null} + + {Object.entries(parsed.statuses.reduce>((counts, status) => { + counts[status.state] = (counts[status.state] ?? 0) + 1 + return counts + }, {})).map(([status, count]) => ( + + ))} +
+ {showDetails ? ( +
+ {parsed.statuses.map((status) => ( +
+
+ + {status.agentId} +
+ {status.text ? ( +
+ {renderText(status.text, { mode: 'auto', collapseLongContent: props.surface === 'inline', surface: props.surface })} +
+ ) : null} +
+ ))} +
+ ) : null} + {showDetails ? : null} +
+ ) + } + } + + if (name === 'close_agent') { + const parsed = parseCodexCloseAgentResult(result) + if (parsed) { + const targets = getCodexAgentTargets(input) + return ( +
+
+ + {targets[0] ? : null} + +
+ {showDetails && parsed.text ? ( +
+ {renderText(parsed.text, { mode: 'auto', collapseLongContent: props.surface === 'inline', surface: props.surface })} +
+ ) : null} + {showDetails ? : null} +
+ ) + } + } + + const text = extractTextFromResult(result) + if (text) { + if (!showDetails) { + return + } + + return ( + <> + {renderText(text, { mode: 'auto', collapseLongContent: props.surface === 'inline', surface: props.surface })} + {typeof result === 'object' ? : null} + + ) + } + + return +} + const SkillResultView: ToolViewComponent = (props: ToolViewProps) => { const { state, result, input } = props.block.tool @@ -848,7 +976,13 @@ export const toolResultViewRegistry: Record = { CodexReasoning: CodexReasoningResultView, CodexPatch: CodexPatchResultView, CodexDiff: CodexDiffResultView, + CodexAgent: CodexAgentResultView, Skill: SkillResultView, + spawn_agent: CodexAgentResultView, + send_input: CodexAgentResultView, + resume_agent: CodexAgentResultView, + wait_agent: CodexAgentResultView, + close_agent: CodexAgentResultView, AskUserQuestion: AskUserQuestionResultView, ExitPlanMode: MarkdownResultView, ask_user_question: AskUserQuestionResultView, diff --git a/web/src/lib/message-window-store.test.ts b/web/src/lib/message-window-store.test.ts index 089076c4..0d4a9162 100644 --- a/web/src/lib/message-window-store.test.ts +++ b/web/src/lib/message-window-store.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, it } from 'vitest' +import type { ApiClient } from '@/api/client' import type { DecryptedMessage, MessageStatus } from '@/types/api' import { appendOptimisticMessage, clearMessageWindow, + fetchLatestMessages, getMessageWindowState, ingestIncomingMessages, markMessagesConsumed, removeOptimisticMessage, + VISIBLE_WINDOW_SIZE, updateMessageStatus, } from '@/lib/message-window-store' @@ -29,6 +32,7 @@ function makeMsg(overrides: Partial = {}): DecryptedMessage { function makeUserMessage(props: { id: string + seq?: number | null localId?: string status?: MessageStatus text?: string @@ -36,7 +40,7 @@ function makeUserMessage(props: { }): DecryptedMessage { return { id: props.id, - seq: null, + seq: props.seq ?? null, localId: props.localId ?? null, content: { role: 'user', @@ -51,6 +55,60 @@ function makeUserMessage(props: { } as DecryptedMessage } +function makeAgentMessage(props: { + id: string + seq?: number | null + createdAt?: number + text?: string +}): DecryptedMessage { + return { + id: props.id, + seq: props.seq ?? null, + localId: null, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'message', + message: props.text ?? 'agent text' + } + } + }, + createdAt: props.createdAt ?? Date.now(), + invokedAt: props.createdAt ?? Date.now() + } as DecryptedMessage +} + +function makeAgentRunMessage(props: { + id: string + seq?: number | null + createdAt?: number + eventType?: 'agent-run-start' | 'agent-run-update' | 'agent-run-trace' +}): DecryptedMessage { + const eventType = props.eventType ?? 'agent-run-update' + return { + id: props.id, + seq: props.seq ?? null, + localId: null, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: eventType, + cardId: 'spawn-1', + agentId: 'agent-1', + status: 'running', + activity: 'Running' + } + } + }, + createdAt: props.createdAt ?? Date.now(), + invokedAt: props.createdAt ?? Date.now() + } as DecryptedMessage +} + describe('removeOptimisticMessage', () => { const SESSION = 'test-session-remove' @@ -166,3 +224,114 @@ describe('message-window-store status updates', () => { expect(message?.status).toBe('sent') }) }) + +describe('message-window-store visible trimming', () => { + const SESSION_ID = 'session-message-window-trim-test' + + afterEach(() => { + clearMessageWindow(SESSION_ID) + }) + + it('does not evict main conversation messages when Codex subagent events flood the window', () => { + const baseTime = 1_700_000_000_000 + const messages: DecryptedMessage[] = [ + makeUserMessage({ + id: 'main-user', + seq: 1, + text: 'main prompt before subagents', + createdAt: baseTime + }) + ] + + for (let i = 0; i < VISIBLE_WINDOW_SIZE + 1; i += 1) { + messages.push(makeAgentRunMessage({ + id: `agent-run-${i}`, + seq: i + 2, + createdAt: baseTime + i + 1 + })) + } + + ingestIncomingMessages(SESSION_ID, messages) + + const state = getMessageWindowState(SESSION_ID) + expect(state.messages.some((message) => message.id === 'main-user')).toBe(true) + expect(state.messages.some((message) => message.id === 'agent-run-0')).toBe(true) + }) + + it('marks the window as pageable when regular live messages are trimmed', () => { + const baseTime = 1_700_000_100_000 + const messages: DecryptedMessage[] = [] + for (let i = 0; i < VISIBLE_WINDOW_SIZE + 1; i += 1) { + messages.push(makeAgentMessage({ + id: `agent-message-${i}`, + seq: i + 1, + createdAt: baseTime + i + })) + } + + ingestIncomingMessages(SESSION_ID, messages) + + const state = getMessageWindowState(SESSION_ID) + expect(state.messages).toHaveLength(VISIBLE_WINDOW_SIZE) + expect(state.messages.some((message) => message.id === 'agent-message-0')).toBe(false) + expect(state.hasMore).toBe(true) + expect(state.oldestSeq).toBe(2) + }) + + it('backfills cold latest load when the newest page is filled by Codex subagent events', async () => { + const baseTime = 1_700_000_200_000 + const latestAgentRuns: DecryptedMessage[] = [] + for (let i = 0; i < 50; i += 1) { + latestAgentRuns.push(makeAgentRunMessage({ + id: `agent-run-latest-${i}`, + seq: i + 2, + createdAt: baseTime + i + 2 + })) + } + const mainMessage = makeUserMessage({ + id: 'main-user-before-agent-flood', + seq: 1, + text: 'main prompt before subagents', + createdAt: baseTime + 1 + }) + + const calls: Array<{ beforeAt?: number | null; beforeSeq?: number | null; limit?: number }> = [] + const api = { + getMessages: async (_sessionId: string, options: { beforeAt?: number | null; beforeSeq?: number | null; limit?: number }) => { + calls.push(options) + if (calls.length === 1) { + return { + messages: latestAgentRuns, + page: { + limit: options.limit ?? 50, + nextBeforeSeq: 2, + nextBeforeAt: baseTime + 2, + hasMore: true + } + } + } + return { + messages: [mainMessage], + page: { + limit: options.limit ?? 200, + nextBeforeSeq: 1, + nextBeforeAt: baseTime + 1, + hasMore: false + } + } + } + } as Pick + + await fetchLatestMessages(api as ApiClient, SESSION_ID) + + const state = getMessageWindowState(SESSION_ID) + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + beforeSeq: 2, + beforeAt: baseTime + 2, + limit: 200 + }) + expect(state.messages.some((message) => message.id === 'main-user-before-agent-flood')).toBe(true) + expect(state.messages.filter((message) => message.id.startsWith('agent-run-latest-'))).toHaveLength(50) + }) +}) diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index 0f32be07..45c96c05 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -1,5 +1,5 @@ import type { ApiClient } from '@/api/client' -import type { DecryptedMessage, MessageStatus } from '@/types/api' +import type { DecryptedMessage, MessageStatus, MessagesResponse } from '@/types/api' import { normalizeDecryptedMessage } from '@/chat/normalize' import { isQueuedForInvocation, isUserMessage, mergeMessages } from '@/lib/messages' @@ -20,7 +20,10 @@ export type MessageWindowState = { export const VISIBLE_WINDOW_SIZE = 400 export const PENDING_WINDOW_SIZE = 200 +const AGENT_RUN_WINDOW_SIZE = 800 const PAGE_SIZE = 50 +const COLD_LOAD_BACKFILL_PAGE_SIZE = 200 +const COLD_LOAD_REGULAR_TARGET = PAGE_SIZE const PENDING_OVERFLOW_WARNING = 'New messages arrived while you were away. Scroll to bottom to refresh.' type InternalState = MessageWindowState & { @@ -214,6 +217,135 @@ function deriveSeqBounds(messages: DecryptedMessage[]): { oldestSeq: number | nu return { oldestSeq: oldest, newestSeq: newest } } +function getMessagePositionAt(message: DecryptedMessage): number { + return message.invokedAt ?? message.createdAt +} + +function deriveOldestPosition(messages: DecryptedMessage[]): { at: number; seq: number } | null { + let oldest: DecryptedMessage | null = null + for (const message of messages) { + if (typeof message.seq !== 'number') continue + if (!oldest) { + oldest = message + continue + } + const messageAt = getMessagePositionAt(message) + const oldestAt = getMessagePositionAt(oldest) + if (messageAt < oldestAt || (messageAt === oldestAt && message.seq < oldest.seq!)) { + oldest = message + } + } + return oldest && typeof oldest.seq === 'number' + ? { at: getMessagePositionAt(oldest), seq: oldest.seq } + : null +} + +function isCodexAgentRunMessage(message: DecryptedMessage): boolean { + const content = message.content + if (!content || typeof content !== 'object') return false + const outer = content as { role?: unknown; content?: unknown } + if (outer.role !== 'agent') return false + const inner = outer.content + if (!inner || typeof inner !== 'object') return false + const payload = inner as { type?: unknown; data?: unknown } + if (payload.type !== 'codex') return false + const data = payload.data + if (!data || typeof data !== 'object') return false + const eventType = (data as { type?: unknown }).type + return eventType === 'agent-run-start' + || eventType === 'agent-run-update' + || eventType === 'agent-run-trace' +} + +function countRegularMessages(messages: DecryptedMessage[]): number { + let count = 0 + const seen = new Set() + for (const message of messages) { + if (seen.has(message.id)) continue + seen.add(message.id) + if (!isCodexAgentRunMessage(message)) { + count += 1 + } + } + return count +} + +function hasV8Cursor(response: MessagesResponse): boolean { + return response.page.nextBeforeAt !== undefined + && response.page.nextBeforeAt !== null + && response.page.nextBeforeSeq !== null +} + +function sameCursor(a: MessagesResponse, b: MessagesResponse): boolean { + return (a.page.nextBeforeAt ?? null) === (b.page.nextBeforeAt ?? null) + && a.page.nextBeforeSeq === b.page.nextBeforeSeq +} + +async function backfillColdLoadMessages( + api: ApiClient, + sessionId: string, + first: MessagesResponse +): Promise { + let combined = first + let regularCount = countRegularMessages(combined.messages) + + // On a cold reload the hub's latest page can be filled entirely by Codex + // child-agent trace updates. The live path protects regular/root messages + // with a separate client budget, but that cannot help if those messages were + // never fetched. Walk older pages until the initial window has a small root + // conversation floor, or until history is exhausted. + while (combined.page.hasMore && regularCount < COLD_LOAD_REGULAR_TARGET) { + if (combined.page.nextBeforeSeq === null) break + + const older = hasV8Cursor(combined) + ? await api.getMessages(sessionId, { + byPosition: true, + beforeAt: combined.page.nextBeforeAt!, + beforeSeq: combined.page.nextBeforeSeq, + limit: COLD_LOAD_BACKFILL_PAGE_SIZE + }) + : await api.getMessages(sessionId, { + beforeSeq: combined.page.nextBeforeSeq, + limit: COLD_LOAD_BACKFILL_PAGE_SIZE + }) + + if (older.messages.length === 0 || sameCursor(combined, older)) { + combined = { + messages: combined.messages, + page: { + ...combined.page, + hasMore: false + } + } + break + } + + combined = { + messages: mergeMessages(older.messages, combined.messages), + page: older.page + } + regularCount = countRegularMessages(combined.messages) + } + + return combined +} + +function sliceForTrim(items: T[], limit: number, mode: 'append' | 'prepend'): { kept: T[]; dropped: T[] } { + if (items.length <= limit) { + return { kept: items, dropped: [] } + } + if (limit <= 0) { + return { kept: [], dropped: items } + } + const kept = mode === 'prepend' + ? items.slice(0, limit) + : items.slice(items.length - limit) + const dropped = mode === 'prepend' + ? items.slice(limit) + : items.slice(0, items.length - limit) + return { kept, dropped } +} + function buildState( prev: InternalState, updates: { @@ -283,31 +415,51 @@ function trimPreservingQueued( return { kept: messages, dropped: [] } } const queued = messages.filter(isQueuedForInvocation) - if (queued.length === 0) { - const kept = mode === 'prepend' - ? messages.slice(0, limit) - : messages.slice(messages.length - limit) - const dropped = mode === 'prepend' - ? messages.slice(limit) - : messages.slice(0, messages.length - limit) - return { kept, dropped } - } const queuedIds = new Set(queued.map((message) => message.id)) - const regular = messages.filter((message) => !queuedIds.has(message.id)) + const nonQueued = messages.filter((message) => !queuedIds.has(message.id)) + const agentRun = nonQueued.filter(isCodexAgentRunMessage) + const regular = nonQueued.filter((message) => !isCodexAgentRunMessage(message)) const budget = Math.max(0, limit - queued.length) - const trimmedRegular = mode === 'prepend' - ? regular.slice(0, budget) - : regular.slice(Math.max(0, regular.length - budget)) - const droppedRegular = mode === 'prepend' - ? regular.slice(budget) - : regular.slice(0, Math.max(0, regular.length - budget)) - return { kept: mergeMessages(trimmedRegular, queued), dropped: droppedRegular } + const regularTrim = sliceForTrim(regular, budget, mode) + const agentRunTrim = sliceForTrim(agentRun, AGENT_RUN_WINDOW_SIZE, mode) + return { + kept: mergeMessages([...regularTrim.kept, ...agentRunTrim.kept], queued), + dropped: [...regularTrim.dropped, ...agentRunTrim.dropped] + } } function trimVisible(messages: DecryptedMessage[], mode: 'append' | 'prepend'): DecryptedMessage[] { return trimPreservingQueued(messages, VISIBLE_WINDOW_SIZE, mode).kept } +function trimVisibleWithDropped( + messages: DecryptedMessage[], + mode: 'append' | 'prepend' +): { kept: DecryptedMessage[]; dropped: DecryptedMessage[] } { + return trimPreservingQueued(messages, VISIBLE_WINDOW_SIZE, mode) +} + +function cursorUpdatesAfterAppendTrim( + kept: DecryptedMessage[], + dropped: DecryptedMessage[] +): { + hasMore?: boolean + oldestPositionAt?: number | null + oldestPositionSeq?: number | null +} { + if (dropped.length === 0) { + return {} + } + const oldest = deriveOldestPosition(kept) + return { + hasMore: true, + ...(oldest ? { + oldestPositionAt: oldest.at, + oldestPositionSeq: oldest.seq + } : {}) + } +} + function trimPending( sessionId: string, messages: DecryptedMessage[] @@ -425,7 +577,10 @@ export async function fetchLatestMessages(api: ApiClient, sessionId: string): Pr // Always request byPosition mode (V8). If the hub is V7 it ignores byPosition and // returns the standard seq-based response (no nextBeforeAt field) — we fall back // to seq-cursor mode seamlessly. - const response = await api.getMessages(sessionId, { byPosition: true, limit: PAGE_SIZE }) + const firstResponse = await api.getMessages(sessionId, { byPosition: true, limit: PAGE_SIZE }) + const response = initial.atBottom + ? await backfillColdLoadMessages(api, sessionId, firstResponse) + : firstResponse // Derive composite cursor pair from server response. Both values come from // the same row on the server; we keep them paired so the next older fetch // doesn't mix `beforeAt` from the server with a recomputed minimum `seq`. @@ -524,9 +679,13 @@ export function ingestIncomingMessages(sessionId: string, incoming: DecryptedMes updateState(sessionId, (prev) => { if (prev.atBottom) { const merged = mergeMessages(prev.messages, incoming) - const trimmed = trimVisible(merged, 'append') - const pending = filterPendingAgainstVisible(prev.pending, trimmed) - return buildState(prev, { messages: trimmed, pending }) + const { kept, dropped } = trimVisibleWithDropped(merged, 'append') + const pending = filterPendingAgainstVisible(prev.pending, kept) + return buildState(prev, { + messages: kept, + pending, + ...cursorUpdatesAfterAppendTrim(kept, dropped) + }) } // 不在底部时:agent 消息立即显示,user 消息才放入 pending // 原因:用户必须看到 AI 回复才能继续交互,pending 机制会导致回复滞后 @@ -536,9 +695,13 @@ export function ingestIncomingMessages(sessionId: string, incoming: DecryptedMes let state = prev if (agentMessages.length > 0) { const merged = mergeMessages(state.messages, agentMessages) - const trimmed = trimVisible(merged, 'append') - const pending = filterPendingAgainstVisible(state.pending, trimmed) - state = buildState(state, { messages: trimmed, pending }) + const { kept, dropped } = trimVisibleWithDropped(merged, 'append') + const pending = filterPendingAgainstVisible(state.pending, kept) + state = buildState(state, { + messages: kept, + pending, + ...cursorUpdatesAfterAppendTrim(kept, dropped) + }) } if (userMessages.length > 0) { const pendingResult = mergeIntoPending(state, userMessages) @@ -562,14 +725,15 @@ export function flushPendingMessages(sessionId: string): boolean { const needsRefresh = current.pendingOverflowVisibleCount > 0 updateState(sessionId, (prev) => { const merged = mergeMessages(prev.messages, prev.pending) - const trimmed = trimVisible(merged, 'append') + const { kept, dropped } = trimVisibleWithDropped(merged, 'append') return buildState(prev, { - messages: trimmed, + messages: kept, pending: [], pendingOverflowCount: 0, pendingVisibleCount: 0, pendingOverflowVisibleCount: 0, warning: needsRefresh ? (prev.warning ?? PENDING_OVERFLOW_WARNING) : prev.warning, + ...cursorUpdatesAfterAppendTrim(kept, dropped) }) }, true) return needsRefresh @@ -587,9 +751,14 @@ export function setAtBottom(sessionId: string, atBottom: boolean): void { export function appendOptimisticMessage(sessionId: string, message: DecryptedMessage): void { updateState(sessionId, (prev) => { const merged = mergeMessages(prev.messages, [message]) - const trimmed = trimVisible(merged, 'append') - const pending = filterPendingAgainstVisible(prev.pending, trimmed) - return buildState(prev, { messages: trimmed, pending, atBottom: true }) + const { kept, dropped } = trimVisibleWithDropped(merged, 'append') + const pending = filterPendingAgainstVisible(prev.pending, kept) + return buildState(prev, { + messages: kept, + pending, + atBottom: true, + ...cursorUpdatesAfterAppendTrim(kept, dropped) + }) }, true) } @@ -721,11 +890,16 @@ export function markMessagesConsumed(sessionId: string, localIds: string[], invo // After update, re-merge to re-sort by the position key (`invokedAt ?? createdAt`): // a queued message that just received `invokedAt` should move to its invocation // position, not stay at its original send-time slot until the next fetch. - const messages = mergeMessages(updateList(prev.messages), consumedFromPending) + const mergedMessages = mergeMessages(updateList(prev.messages), consumedFromPending) + const { kept, dropped } = trimVisibleWithDropped(mergedMessages, 'append') const pending = mergeMessages([], remainingPending) if (!changed) { return prev } - return buildState(prev, { messages, pending }) + return buildState(prev, { + messages: kept, + pending, + ...cursorUpdatesAfterAppendTrim(kept, dropped) + }) }) }