diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 4b2118fe..b5b2a47f 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -74,6 +74,7 @@ import { createAgentTextThoughtChunk, createUserMessageChunk, } from "./ContentChunks"; +import { getSubAgentActivityTracker } from "./SubAgentActivityTracker"; export interface ThreadGoalSnapshot { objective: string; @@ -178,7 +179,7 @@ export class CodexAcpServer { this.getRecentStderr = getRecentStderr ?? (() => ""); this.clientInfo = null; this.clientCapabilities = null; - this.terminalOutputMode = "terminal_output_delta"; + this.terminalOutputMode = "content"; this.booleanConfigOptionsSupported = false; this.availableCommands = new CodexCommands( connection, @@ -999,9 +1000,10 @@ export class CodexAcpServer { case "userMessage": return this.createUserMessageUpdates(item); case "hookPrompt": - case "subAgentActivity": case "sleep": return []; + case "subAgentActivity": + return getSubAgentActivityTracker(sessionState).mapSubAgentActivity(item, "completed"); case "agentMessage": { const meta = createCodexMessagePhaseMeta(item.phase); return [{ @@ -1016,7 +1018,7 @@ export class CodexAcpServer { case "fileChange": return [await createFileChangeUpdate(item)]; case "commandExecution": { - const updates = [await createCommandExecutionUpdate(item)]; + const updates = [await createCommandExecutionUpdate(item, sessionState.terminalOutputMode)]; const completeUpdate = createCommandExecutionCompleteUpdate(item, sessionState.terminalOutputMode); if (completeUpdate) { updates.push(completeUpdate); @@ -1028,7 +1030,7 @@ export class CodexAcpServer { case "dynamicToolCall": return [await createDynamicToolCallUpdate(item)]; case "collabAgentToolCall": - return [createCollabAgentToolCallUpdate(item)]; + return getSubAgentActivityTracker(sessionState).mapCollabAgentToolCall(item, "completed"); case "webSearch": return [this.createWebSearchUpdate(item)]; case "imageView": @@ -1691,9 +1693,9 @@ function mergeHistoryUpdates( const seen = new Set(); let fallbackIndex = 0; - const pushUpdate = (update: UpdateSessionEvent) => { + const pushUpdate = (update: UpdateSessionEvent, dedupe: boolean = true) => { const key = historyUpdateKey(update); - if (key && seen.has(key)) { + if (dedupe && key && seen.has(key)) { return; } if (key) { @@ -1729,7 +1731,10 @@ function mergeHistoryUpdates( for (const update of threadUpdates) { flushFallbackBeforeMatchingDuplicate(update); - pushUpdate(update); + // Thread history is authoritative and can legitimately contain several + // progress updates for the same tool call. Only fallback records are + // deduplicated against those updates. + pushUpdate(update, false); } while (fallbackIndex < responseItemFallbackUpdates.length) { diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 11d61a6e..79d69109 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -140,6 +140,7 @@ export class CodexAppServerClient { private readonly threadGoalUpdateCaptures = new Map void>>(); private readonly threadGoalClearedCaptures = new Map void>>(); private readonly staleTurnIds = new Map>(); + private readonly childThreadParents = new Map(); constructor(connection: MessageConnection) { this.connection = connection; @@ -173,6 +174,7 @@ export class CodexAppServerClient { if (this.handleStaleTurnNotification(serverNotification, routing)) { return; } + this.recordChildThreadParent(serverNotification); this.recordTurnRouting(routing); if (this.handleStaleTurnNotification(serverNotification, routing)) { return; @@ -251,6 +253,12 @@ export class CodexAppServerClient { this.notificationHandlers.delete(threadId); this.approvalHandlers.delete(threadId); this.elicitationHandlers.delete(threadId); + this.childThreadParents.delete(threadId); + for (const [childThreadId, parentThreadId] of this.childThreadParents) { + if (parentThreadId === threadId) { + this.childThreadParents.delete(childThreadId); + } + } } async initialize(params: InitializeParams): Promise { @@ -654,6 +662,15 @@ export class CodexAppServerClient { if (handler) { handler(notification); } + const parentThreadId = this.childThreadParents.get(threadId); + const parentHandler = parentThreadId ? this.notificationHandlers.get(parentThreadId) : undefined; + if ( + parentHandler + && parentHandler !== handler + && isChildActivityNotification(notification) + ) { + parentHandler(notification); + } return; } for (const notificationHandler of this.notificationHandlers.values()) { @@ -661,6 +678,25 @@ export class CodexAppServerClient { } } + private recordChildThreadParent(notification: ServerNotification): void { + if (notification.method !== "item/started" && notification.method !== "item/completed") { + return; + } + const item = notification.params.item; + if (item.type === "subAgentActivity") { + this.childThreadParents.set(item.agentThreadId, notification.params.threadId); + return; + } + if ( + item.type === "collabAgentToolCall" + && (item.tool === "spawnAgent" || item.tool === "resumeAgent") + ) { + for (const childThreadId of item.receiverThreadIds) { + this.childThreadParents.set(childThreadId, notification.params.threadId); + } + } + } + private recordTurnCompleted(event: TurnCompletedNotification): void { const threadResolvers = this.pendingTurnCompletionResolvers.get(event.threadId); const resolve = threadResolvers?.get(event.turn.id); @@ -973,6 +1009,14 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica return notification.method === "turn/completed"; } +function isChildActivityNotification(notification: ServerNotification): boolean { + if (notification.method === "turn/completed") { + return true; + } + return notification.method === "item/completed" + && notification.params.item.type === "agentMessage"; +} + function isThreadStatusChangedNotification(notification: ServerNotification): notification is { method: "thread/status/changed"; params: ThreadStatusChangedNotification; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 456aacfd..36baf1a5 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -33,9 +33,8 @@ import type { McpStartupCompleteEvent } from "./app-server"; import {toTokenCount} from "./TokenCount"; import { commandExecutionUsesTerminalOutput, - createCollabAgentToolCallCompleteUpdate, - createCollabAgentToolCallUpdate, createCommandExecutionUpdate, + createCommandExecutionCompleteUpdate, createContextCompactionCompleteUpdate, createContextCompactionStartUpdate, createDynamicToolCallUpdate, @@ -62,9 +61,12 @@ import { createAgentTextMessageChunk, createAgentTextThoughtChunk, } from "./ContentChunks"; +import { getSubAgentActivityTracker } from "./SubAgentActivityTracker"; export { stripShellPrefix }; +type UpdateResult = UpdateSessionEvent | UpdateSessionEvent[] | null; + export class CodexEventHandler { private readonly connection: AcpClientConnection; @@ -90,13 +92,14 @@ export class CodexEventHandler { async handleNotification(notification: ServerNotification) { const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId); - const updateEvent = await this.createUpdateEvent(notification); - if (updateEvent) { + const result = await this.createUpdateEvent(notification); + const updateEvents = Array.isArray(result) ? result : result ? [result] : []; + for (const updateEvent of updateEvents) { await session.update(updateEvent); } } - private async createUpdateEvent(notification: ServerNotification): Promise { + private async createUpdateEvent(notification: ServerNotification): Promise { /* TODO split UpdateSessionEvent to improve completion createUpdateEvent({ @@ -119,6 +122,12 @@ export class CodexEventHandler { this.sessionState.currentTurnId = notification.params.turn.id; return null; case "turn/completed": + if (notification.params.threadId !== this.sessionState.sessionId) { + return getSubAgentActivityTracker(this.sessionState).completeChildTurn( + notification.params.threadId, + notification.params.turn, + ); + } this.sessionState.currentTurnId = null; return null; case "thread/tokenUsage/updated": @@ -309,18 +318,21 @@ export class CodexEventHandler { return createAgentTextThoughtChunk(text, messageId); } - private async createItemEvent(event: ItemStartedNotification): Promise { + private async createItemEvent(event: ItemStartedNotification): Promise { switch (event.item.type) { case "fileChange": return await createFileChangeUpdate(event.item); case "commandExecution": { - if (commandExecutionUsesTerminalOutput(event.item)) { + if ( + this.sessionState.terminalOutputMode !== "content" + && commandExecutionUsesTerminalOutput(event.item) + ) { this.terminalCommandIds.add(event.item.id); } else { this.terminalCommandIds.delete(event.item.id); this.terminalCommandOutputIds.delete(event.item.id); } - return await createCommandExecutionUpdate(event.item); + return await createCommandExecutionUpdate(event.item, this.sessionState.terminalOutputMode); } case "mcpToolCall": return await createMcpToolCallUpdate(event.item); @@ -335,13 +347,14 @@ export class CodexEventHandler { this.activeImageGenerationItems.add(event.item.id); return createImageGenerationStartUpdate(event.item); case "collabAgentToolCall": - return createCollabAgentToolCallUpdate(event.item); + return getSubAgentActivityTracker(this.sessionState).mapCollabAgentToolCall(event.item, "started"); case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; case "contextCompaction": return createContextCompactionStartUpdate(event.item); case "subAgentActivity": + return getSubAgentActivityTracker(this.sessionState).mapSubAgentActivity(event.item, "started"); case "sleep": case "userMessage": case "hookPrompt": @@ -353,7 +366,7 @@ export class CodexEventHandler { } } - private async completeItemEvent(event: ItemCompletedNotification): Promise { + private async completeItemEvent(event: ItemCompletedNotification): Promise { switch (event.item.type) { case "fileChange": case "dynamicToolCall": @@ -390,8 +403,15 @@ export class CodexEventHandler { case "webSearch": return createWebSearchCompleteUpdate(event.item); case "collabAgentToolCall": - return createCollabAgentToolCallCompleteUpdate(event.item); + return getSubAgentActivityTracker(this.sessionState).mapCollabAgentToolCall(event.item, "completed"); case "agentMessage": + if (event.threadId !== this.sessionState.sessionId) { + getSubAgentActivityTracker(this.sessionState).recordChildMessage( + event.threadId, + event.item.text, + ); + return null; + } this.rememberAgentMessagePhase(event.item); return null; case "exitedReviewMode": @@ -400,6 +420,7 @@ export class CodexEventHandler { return createContextCompactionCompleteUpdate(event.item); //ignored types case "subAgentActivity": + return getSubAgentActivityTracker(this.sessionState).mapSubAgentActivity(event.item, "completed"); case "sleep": case "userMessage": case "hookPrompt": @@ -436,7 +457,7 @@ export class CodexEventHandler { } private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { - if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) { + if (event.delta.length > 0) { this.terminalCommandOutputIds.add(event.itemId); } return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId)); @@ -447,6 +468,16 @@ export class CodexEventHandler { data: string, terminalOutputMode: TerminalOutputMode ): UpdateSessionEvent { + if (terminalOutputMode === "content") { + return { + sessionUpdate: "tool_call_update", + toolCallId: itemId, + content: [{ + type: "content", + content: { type: "text", text: data }, + }], + }; + } return { sessionUpdate: "tool_call_update", toolCallId: itemId, @@ -518,37 +549,16 @@ export class CodexEventHandler { } private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent { - const update: UpdateSessionEvent = { - sessionUpdate: "tool_call_update", - toolCallId: item.id, - status: item.status === "completed" ? "completed" : "failed", - rawOutput: { - formatted_output: item.aggregatedOutput ?? "", - exit_code: item.exitCode - }, - }; - const commandHadTerminal = this.terminalCommandIds.delete(item.id); const commandHadOutput = this.terminalCommandOutputIds.delete(item.id); - if (!commandHadTerminal) { - return update; - } - const terminalMeta: Record = {}; - if (!commandHadOutput && item.aggregatedOutput) { - Object.assign( - terminalMeta, - createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput) - ); - } - terminalMeta["terminal_exit"] = { - exit_code: item.exitCode, - signal: null, - terminal_id: item.id - }; - return { - ...update, - _meta: terminalMeta, - }; + return createCommandExecutionCompleteUpdate( + item, + this.sessionState.terminalOutputMode, + { + includeOutputContent: !commandHadOutput, + includeTerminalMeta: commandHadTerminal, + }, + )!; } private async updatePlan(event: TurnPlanUpdatedNotification): Promise { diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 20465fc2..28c50d2e 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -77,10 +77,14 @@ export async function createFileChangeUpdate( }; } -export async function createCommandExecutionUpdate(item: CommandExecutionItem): Promise { +// Use portable ACP content unless the client negotiated terminal metadata. +export async function createCommandExecutionUpdate( + item: CommandExecutionItem, + terminalOutputMode: TerminalOutputMode = "content", +): Promise { const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; if (commandAction) { - return createCommandActionEvent(item.id, item.status, item.cwd, commandAction); + return createCommandActionEvent(item.id, item.status, item.cwd, commandAction, terminalOutputMode); } const command = stripShellPrefix(item.command); return createTerminalCommandEvent({ @@ -93,12 +97,14 @@ export async function createCommandExecutionUpdate(item: CommandExecutionItem): command: item.command, cwd: item.cwd, }, - }, item.id, item.cwd); + }, item.id, item.cwd, terminalOutputMode); } +// Complete command calls with portable output and exit details. export function createCommandExecutionCompleteUpdate( item: CommandExecutionItem, terminalOutputMode: TerminalOutputMode, + options: { includeOutputContent?: boolean; includeTerminalMeta?: boolean } = {}, ): UpdateSessionEvent | null { if (item.status === "inProgress") { return null; @@ -114,12 +120,25 @@ export function createCommandExecutionCompleteUpdate( }, }; + if (terminalOutputMode === "content") { + const output = item.aggregatedOutput ?? ""; + return { + ...update, + ...(options.includeOutputContent !== false && output.length > 0 + ? { content: [createTextToolCallContent(output)] } + : {}), + }; + } + if (!commandExecutionUsesTerminalOutput(item)) { return update; } + if (options.includeTerminalMeta === false) { + return update; + } const terminalMeta: Record = {}; - if (item.aggregatedOutput) { + if (options.includeOutputContent !== false && item.aggregatedOutput) { Object.assign( terminalMeta, createTerminalOutputMeta(terminalOutputMode, item.id, item.aggregatedOutput), @@ -464,7 +483,8 @@ export function createCommandActionEvent( id: string, status: CommandExecutionStatus, cwd: string, - commandAction: CommandAction + commandAction: CommandAction, + terminalOutputMode: TerminalOutputMode = "content", ): AcpToolCallEvent { const acpStatus = toAcpStatus(status); switch (commandAction.type) { @@ -508,7 +528,7 @@ export function createCommandActionEvent( command: commandAction.command, cwd, }, - }, id, cwd); + }, id, cwd, terminalOutputMode); } } @@ -517,11 +537,16 @@ export function commandExecutionUsesTerminalOutput(item: CommandExecutionItem): return commandAction === undefined || commandAction.type === "unknown"; } +// Add synthetic terminal content only when the client negotiated the extension. function createTerminalCommandEvent( event: AcpToolCallEvent, terminalId: string, cwd: string, + terminalOutputMode: TerminalOutputMode, ): AcpToolCallEvent { + if (terminalOutputMode === "content") { + return event; + } const { rawInput, ...eventWithoutRawInput } = event; return { ...eventWithoutRawInput, @@ -536,6 +561,13 @@ function createTerminalCommandEvent( }; } +function createTextToolCallContent(text: string): ToolCallContent { + return { + type: "content", + content: { type: "text", text }, + }; +} + function createSearchTitle(query: string | null, path: string | null): string { if (query && path) { return `Search for '${query}' in ${path}`; diff --git a/src/ResponseItemHistoryFallback.ts b/src/ResponseItemHistoryFallback.ts index 7292df20..6fb1ca35 100644 --- a/src/ResponseItemHistoryFallback.ts +++ b/src/ResponseItemHistoryFallback.ts @@ -113,7 +113,7 @@ export function parseResponseItemHistoryFallback( if (toolCallId && emittedToolCallIds.has(toolCallId)) { break; } - const result = createFunctionCallUpdate(item); + const result = createFunctionCallUpdate(item, terminalOutputMode); if (!result) { break; } @@ -361,7 +361,10 @@ function textParts(value: unknown): string[] { }); } -function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | null { +function createFunctionCallUpdate( + item: JsonRecord, + terminalOutputMode: TerminalOutputMode, +): LegacyFunctionCallUpdate | null { const toolCallId = stringValue(item["call_id"]); const name = stringValue(item["name"]); if (!toolCallId || !name) { @@ -375,7 +378,7 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | const commandAction = command ? inferCommandAction(command, cwd) : null; if (commandAction) { return { - update: createCommandActionEvent(toolCallId, "inProgress", cwd, commandAction), + update: createCommandActionEvent(toolCallId, "inProgress", cwd, commandAction, terminalOutputMode), usesTerminal: false, isExecCommand, }; @@ -390,7 +393,7 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate | rawInput: rawInputForFunctionCall(name, args), }; - if (!functionCallUsesTerminal(item)) { + if (terminalOutputMode === "content" || !functionCallUsesTerminal(item)) { return { update, usesTerminal: false, isExecCommand }; } @@ -421,6 +424,14 @@ function createFunctionCallOutputUpdate( toolCallId, status, rawOutput: { output: item["output"] }, + ...(execToolCallIds.has(toolCallId) && output.length > 0 + ? { + content: [{ + type: "content", + content: { type: "text", text: output }, + }], + } + : {}), }; } diff --git a/src/SubAgentActivityTracker.ts b/src/SubAgentActivityTracker.ts new file mode 100644 index 00000000..c31adaa6 --- /dev/null +++ b/src/SubAgentActivityTracker.ts @@ -0,0 +1,383 @@ +import type { UpdateSessionEvent } from "./ACPSessionConnection"; +import type { CollabAgentState, ThreadItem, Turn } from "./app-server/v2"; + +type CollabAgentToolCallItem = Extract; +type SubAgentActivityItem = Extract; +type AcpToolCallStatus = "in_progress" | "completed" | "failed"; +type Activity = { + agentThreadId: string; + toolCallId: string; + agentPath: string | null; + status: AcpToolCallStatus; +}; + +const trackers = new WeakMap(); + +export function getSubAgentActivityTracker(owner: object): SubAgentActivityTracker { + const existing = trackers.get(owner); + if (existing) { + return existing; + } + const tracker = new SubAgentActivityTracker(); + trackers.set(owner, tracker); + return tracker; +} + +export class SubAgentActivityTracker { + private readonly activities = new Map(); + private readonly seenSubAgentItems = new Set(); + private readonly childMessages = new Map(); + + recordChildMessage(agentThreadId: string, text: string): void { + if (text.trim().length > 0) { + this.childMessages.set(agentThreadId, text); + } + } + + // Complete the parent-visible activity when its child turn reaches a terminal state. + completeChildTurn(agentThreadId: string, turn: Turn): UpdateSessionEvent[] { + const activity = this.activities.get(agentThreadId); + if (!activity || turn.status === "inProgress") { + return []; + } + const status: AcpToolCallStatus = turn.status === "completed" ? "completed" : "failed"; + activity.status = status; + const result = this.childMessages.get(agentThreadId) ?? null; + this.childMessages.delete(agentThreadId); + const error = turn.error?.message ?? null; + const message = result ?? error; + return [{ + sessionUpdate: "tool_call_update", + toolCallId: activity.toolCallId, + title: this.activityTitle(activity), + status, + ...(message ? { + content: [{ + type: "content", + content: { type: "text", text: message }, + }], + } : {}), + rawOutput: { + agentThreadId, + ...(activity.agentPath ? { agentPath: activity.agentPath } : {}), + turnId: turn.id, + turnStatus: turn.status, + result, + error: turn.error, + }, + _meta: this.activityMeta(agentThreadId, activity.toolCallId), + }]; + } + + // Map app-server subagent lifecycle items to one parent-visible ACP activity. + mapSubAgentActivity( + item: SubAgentActivityItem, + lifecycle: "started" | "completed", + ): UpdateSessionEvent[] { + if (lifecycle === "completed" && this.seenSubAgentItems.delete(item.id)) { + return []; + } + if (lifecycle === "started") { + this.seenSubAgentItems.add(item.id); + } + + const existing = this.activities.get(item.agentThreadId); + switch (item.kind) { + case "started": { + if (existing && existing.status === "in_progress") { + existing.agentPath = item.agentPath; + return [this.createActivityUpdate(existing, { + activity: item.kind, + agentPath: item.agentPath, + })]; + } + return [this.createActivity(item.agentThreadId, item.id, item.agentPath, { + activity: item.kind, + })]; + } + case "interacted": { + if (!existing || existing.status !== "in_progress") { + return [this.createActivity(item.agentThreadId, item.id, item.agentPath, { + activity: item.kind, + })]; + } + existing.agentPath = item.agentPath; + return [this.createActivityUpdate(existing, { + activity: item.kind, + agentPath: item.agentPath, + })]; + } + case "interrupted": { + if (!existing || existing.status !== "in_progress") { + return [this.createActivity(item.agentThreadId, item.id, item.agentPath, { + activity: item.kind, + }, "failed")]; + } + existing.status = "failed"; + return [this.createActivityUpdate(existing, { + activity: item.kind, + agentPath: item.agentPath, + }, "failed")]; + } + } + } + + // Fold collaboration controls into the active run and start a new run on resume. + mapCollabAgentToolCall( + item: CollabAgentToolCallItem, + lifecycle: "started" | "completed", + ): UpdateSessionEvent[] { + const targetThreadIds = this.targetThreadIds(item); + + switch (item.tool) { + case "spawnAgent": + return this.mapStartAction(item, targetThreadIds, lifecycle, "Start subagent"); + case "resumeAgent": + return this.mapStartAction(item, targetThreadIds, lifecycle, "Resume subagent"); + case "sendInput": + case "wait": + case "closeAgent": + if (lifecycle === "started") { + return []; + } + return targetThreadIds.flatMap((threadId) => this.updateForControlAction(item, threadId)); + } + } + + private mapStartAction( + item: CollabAgentToolCallItem, + targetThreadIds: string[], + lifecycle: "started" | "completed", + failedTitle: string, + ): UpdateSessionEvent[] { + if (targetThreadIds.length === 0) { + if (lifecycle === "completed" && item.status === "failed") { + return [{ + sessionUpdate: "tool_call", + toolCallId: item.id, + kind: "other", + title: failedTitle, + status: "failed", + rawInput: this.startRawInput(item), + rawOutput: this.actionRawOutput(item, null), + _meta: this.activityMeta(null, item.id), + }]; + } + return []; + } + + return targetThreadIds.flatMap((threadId, index) => { + const current = this.activities.get(threadId); + const needsNewRun = !current || current.status !== "in_progress"; + if (needsNewRun) { + const toolCallId = targetThreadIds.length === 1 ? item.id : `${item.id}:${index}`; + const state = item.agentsStates[threadId] ?? null; + const status = this.startActionStatus(item, state); + return [this.createActivity( + threadId, + toolCallId, + current?.agentPath ?? null, + this.actionRawOutput(item, state), + status, + item, + )]; + } + + if (lifecycle === "started") { + return []; + } + const state = item.agentsStates[threadId] ?? null; + const status = this.stateStatus(state) ?? current.status; + current.status = status; + return [this.createActivityUpdate(current, this.actionRawOutput(item, state), status)]; + }); + } + + private updateForControlAction( + item: CollabAgentToolCallItem, + threadId: string, + ): UpdateSessionEvent[] { + const state = item.agentsStates[threadId] ?? null; + let activity = this.activities.get(threadId); + if (!activity) { + const status = this.stateStatus(state) ?? "in_progress"; + const toolCallId = `${item.id}:${encodeURIComponent(threadId)}`; + return [this.createActivity(threadId, toolCallId, null, this.actionRawOutput(item, state), status)]; + } + + let status = this.stateStatus(state) ?? activity.status; + if (item.tool === "closeAgent" && item.status === "completed" && status === "in_progress") { + status = "completed"; + } + activity.status = status; + return [this.createActivityUpdate(activity, this.actionRawOutput(item, state), status, item.prompt)]; + } + + private createActivity( + agentThreadId: string, + toolCallId: string, + agentPath: string | null, + rawOutput: Record, + status: AcpToolCallStatus = "in_progress", + startItem?: CollabAgentToolCallItem, + ): UpdateSessionEvent { + const activity: Activity = { agentThreadId, toolCallId, agentPath, status }; + const rawInput = this.activityRawInput(activity, startItem); + this.activities.set(agentThreadId, activity); + return { + sessionUpdate: "tool_call", + toolCallId, + kind: "other", + title: this.activityTitle(activity), + status, + ...(rawInput ? { rawInput } : {}), + rawOutput: { + agentThreadId, + ...(agentPath ? { agentPath } : {}), + ...rawOutput, + }, + _meta: this.activityMeta(agentThreadId, toolCallId), + }; + } + + private createActivityUpdate( + activity: Activity, + rawOutput: Record, + status: AcpToolCallStatus = activity.status, + prompt: string | null = null, + ): UpdateSessionEvent { + const message = this.activityMessage(rawOutput, prompt); + return { + sessionUpdate: "tool_call_update", + toolCallId: activity.toolCallId, + title: this.activityTitle(activity), + status, + ...(message ? { + content: [{ + type: "content", + content: { type: "text", text: message }, + }], + } : {}), + rawOutput: { + agentThreadId: activity.agentThreadId, + ...(activity.agentPath ? { agentPath: activity.agentPath } : {}), + ...rawOutput, + }, + _meta: this.activityMeta(activity.agentThreadId, activity.toolCallId), + }; + } + + private startRawInput(item: CollabAgentToolCallItem): Record { + return { + prompt: item.prompt, + model: item.model, + reasoningEffort: item.reasoningEffort, + }; + } + + private actionRawOutput( + item: CollabAgentToolCallItem, + state: CollabAgentState | null, + ): Record { + return { + action: item.tool, + actionStatus: item.status, + senderThreadId: item.senderThreadId, + receiverThreadIds: item.receiverThreadIds, + prompt: item.prompt, + agentState: state, + }; + } + + private startActionStatus( + item: CollabAgentToolCallItem, + state: CollabAgentState | null, + ): AcpToolCallStatus { + if (item.status === "failed") { + return "failed"; + } + return this.stateStatus(state) ?? "in_progress"; + } + + private stateStatus(state: CollabAgentState | null): AcpToolCallStatus | null { + switch (state?.status) { + case "pendingInit": + case "running": + return "in_progress"; + case "completed": + case "shutdown": + return "completed"; + case "interrupted": + case "errored": + case "notFound": + return "failed"; + case undefined: + return null; + } + } + + private targetThreadIds(item: CollabAgentToolCallItem): string[] { + return Array.from(new Set([ + ...item.receiverThreadIds, + ...Object.keys(item.agentsStates), + ])); + } + + private activityTitle(activity: Activity): string { + return this.activityDescription(activity) ?? "Agent activity"; + } + + private activityDescription(activity: Activity): string | null { + // Use the canonical task name as the client-facing description. + const segments = activity.agentPath?.split("/").filter(Boolean); + const taskName = segments?.[segments.length - 1]; + if (!taskName || taskName === "root") { + return null; + } + const readableTaskName = taskName.replace(/_+/g, " ").trim(); + if (!readableTaskName) { + return null; + } + return `${readableTaskName.charAt(0).toUpperCase()}${readableTaskName.slice(1)}`; + } + + private activityRawInput( + activity: Activity, + startItem?: CollabAgentToolCallItem, + ): Record | null { + const description = this.activityDescription(activity); + if (!description && !startItem) { + return null; + } + return { + ...(description ? { description } : {}), + ...(startItem ? this.startRawInput(startItem) : {}), + }; + } + + private activityMeta(agentThreadId: string | null, runId: string): Record { + return { + codex: { + toolName: "Agent", + subAgent: { + agentThreadId, + runId, + }, + }, + }; + } + + private activityMessage(rawOutput: Record, prompt: string | null): string | null { + const state = rawOutput["agentState"]; + if (state && typeof state === "object" && "message" in state && typeof state.message === "string") { + return state.message; + } + if (rawOutput["actionStatus"] === "failed") { + return `Subagent ${String(rawOutput["action"])} failed.`; + } + if (prompt) { + return `Sent follow-up to subagent: ${prompt}`; + } + return null; + } +} diff --git a/src/TerminalOutputMode.ts b/src/TerminalOutputMode.ts index a04b62db..3ab3de74 100644 --- a/src/TerminalOutputMode.ts +++ b/src/TerminalOutputMode.ts @@ -1,6 +1,6 @@ import type * as acp from "@agentclientprotocol/sdk"; -export type TerminalOutputMode = "terminal_output" | "terminal_output_delta"; +export type TerminalOutputMode = "content" | "terminal_output" | "terminal_output_delta"; export function resolveTerminalOutputMode( clientCapabilities?: acp.ClientCapabilities | null @@ -9,7 +9,10 @@ export function resolveTerminalOutputMode( if (meta?.["terminal_output"] === true) { return "terminal_output"; } - return "terminal_output_delta"; + if (meta?.["terminal_output_delta"] === true) { + return "terminal_output_delta"; + } + return "content"; } export function createTerminalOutputMeta( @@ -18,6 +21,8 @@ export function createTerminalOutputMeta( data: string ): Record { switch (mode) { + case "content": + return {}; case "terminal_output": return { terminal_output: { diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index 9964d2d1..361add3c 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -24,33 +24,34 @@ describe("CodexEventHandler - collab agent tool call events", () => { agentMode: AgentMode.DEFAULT_AGENT_MODE, }); - it("maps live collab agent tool calls to ACP tool call updates", async () => { + // Covers one activity per subagent run, including controls, resume, and failed spawn. + it("folds collaboration controls into coherent subagent run activities", async () => { const notifications: ServerNotification[] = [ { - method: "item/started", + method: "item/completed", params: { threadId: sessionId, turnId: "turn-1", - startedAtMs: 0, + completedAtMs: 0, item: { - type: "collabAgentToolCall", - id: "call-spawn-weather", - tool: "spawnAgent", - status: "inProgress", - senderThreadId: "thread-main", - receiverThreadIds: ["thread-paris"], - prompt: "Find the current weather in Paris.", - model: null, - reasoningEffort: null, - agentsStates: { - "thread-paris": { - status: "running", - message: "Checking weather", - }, - }, + type: "subAgentActivity", + id: "activity-initial", + kind: "started", + agentThreadId: "thread-paris", + agentPath: "/root/weather_audit", }, }, }, + emptyWait("wait-1", "started"), + childMessage("child-result-1", "@agentclientprotocol/codex-acp"), + childTurnCompleted("child-turn-1"), + emptyWait("wait-1", "completed"), + collabStarted("resume-1", "resumeAgent", "running"), + collabCompleted("resume-1", "resumeAgent", "running"), + collabCompleted("send-1", "sendInput", "running", "Check alerts too."), + childMessage("child-result-2", "No alerts found."), + childTurnCompleted("child-turn-2"), + collabCompleted("close-1", "closeAgent", "shutdown"), { method: "item/completed", params: { @@ -59,20 +60,15 @@ describe("CodexEventHandler - collab agent tool call events", () => { completedAtMs: 0, item: { type: "collabAgentToolCall", - id: "call-spawn-weather", + id: "spawn-failed", tool: "spawnAgent", - status: "completed", + status: "failed", senderThreadId: "thread-main", - receiverThreadIds: ["thread-paris"], - prompt: "Find the current weather in Paris.", + receiverThreadIds: [], + prompt: "Try an unavailable worker.", model: null, reasoningEffort: null, - agentsStates: { - "thread-paris": { - status: "completed", - message: null, - }, - }, + agentsStates: {}, }, }, }, @@ -84,4 +80,144 @@ describe("CodexEventHandler - collab agent tool call events", () => { "data/collab-agent-tool-call-flow.json" ); }); + + function collabStarted( + id: string, + tool: "resumeAgent", + agentStatus: "running", + ): ServerNotification { + return { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: collabItem(id, tool, "inProgress", agentStatus), + }, + }; + } + + function emptyWait(id: string, lifecycle: "started" | "completed"): ServerNotification { + return lifecycle === "started" + ? { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "collabAgentToolCall", + id, + tool: "wait", + status: "inProgress", + senderThreadId: "thread-main", + receiverThreadIds: [], + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + }, + }, + } + : { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "collabAgentToolCall", + id, + tool: "wait", + status: "completed", + senderThreadId: "thread-main", + receiverThreadIds: [], + prompt: null, + model: null, + reasoningEffort: null, + agentsStates: {}, + }, + }, + }; + } + + function childMessage(id: string, text: string): ServerNotification { + return { + method: "item/completed", + params: { + threadId: "thread-paris", + turnId: "child-turn", + completedAtMs: 0, + item: { + type: "agentMessage", + id, + text, + phase: "final_answer", + memoryCitation: null, + }, + }, + }; + } + + function childTurnCompleted(turnId: string): ServerNotification { + return { + method: "turn/completed", + params: { + threadId: "thread-paris", + turn: { + id: turnId, + items: [], + itemsView: "notLoaded", + status: "completed", + error: null, + startedAt: 0, + completedAt: 1, + durationMs: 1000, + }, + }, + }; + } + + function collabCompleted( + id: string, + tool: "wait" | "resumeAgent" | "sendInput" | "closeAgent", + agentStatus: "completed" | "running" | "shutdown", + prompt: string | null = null, + ): ServerNotification { + return { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: collabItem(id, tool, "completed", agentStatus, prompt), + }, + }; + } + + function collabItem( + id: string, + tool: "wait" | "resumeAgent" | "sendInput" | "closeAgent", + status: "inProgress" | "completed", + agentStatus: "completed" | "running" | "shutdown", + prompt: string | null = null, + ): Extract["params"]["item"] & { type: "collabAgentToolCall" } { + return { + type: "collabAgentToolCall", + id, + tool, + status, + senderThreadId: "thread-main", + receiverThreadIds: ["thread-paris"], + prompt, + model: null, + reasoningEffort: null, + agentsStates: { + "thread-paris": { + status: agentStatus, + message: agentStatus === "running" ? "Working" : null, + }, + }, + }; + } }); diff --git a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json index 8e7ed13c..9dfb903b 100644 --- a/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json +++ b/src/__tests__/CodexACPAgent/data/collab-agent-tool-call-flow.json @@ -5,23 +5,111 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "tool_call", - "toolCallId": "call-spawn-weather", + "toolCallId": "activity-initial", "kind": "other", - "title": "spawnAgent", + "title": "Weather audit", "status": "in_progress", "rawInput": { - "prompt": "Find the current weather in Paris.", + "description": "Weather audit" + }, + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "activity": "started" + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "activity-initial" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "activity-initial", + "title": "Weather audit", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "@agentclientprotocol/codex-acp" + } + } + ], + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "turnId": "child-turn-1", + "turnStatus": "completed", + "result": "@agentclientprotocol/codex-acp", + "error": null + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "activity-initial" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "resume-1", + "kind": "other", + "title": "Weather audit", + "status": "in_progress", + "rawInput": { + "description": "Weather audit", + "prompt": null, + "model": null, + "reasoningEffort": null + }, + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "action": "resumeAgent", + "actionStatus": "inProgress", "senderThreadId": "thread-main", "receiverThreadIds": [ "thread-paris" ], - "agentsStates": { - "thread-paris": { - "status": "running", - "message": "Checking weather" + "prompt": null, + "agentState": { + "status": "running", + "message": "Working" + } + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "resume-1" } - }, - "status": "inProgress" + } } } } @@ -34,22 +122,203 @@ "sessionId": "test-session-id", "update": { "sessionUpdate": "tool_call_update", - "toolCallId": "call-spawn-weather", - "title": "spawnAgent", + "toolCallId": "resume-1", + "title": "Weather audit", + "status": "in_progress", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Working" + } + } + ], + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "action": "resumeAgent", + "actionStatus": "completed", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ], + "prompt": null, + "agentState": { + "status": "running", + "message": "Working" + } + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "resume-1" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "resume-1", + "title": "Weather audit", + "status": "in_progress", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Working" + } + } + ], + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "action": "sendInput", + "actionStatus": "completed", + "senderThreadId": "thread-main", + "receiverThreadIds": [ + "thread-paris" + ], + "prompt": "Check alerts too.", + "agentState": { + "status": "running", + "message": "Working" + } + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "resume-1" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "resume-1", + "title": "Weather audit", "status": "completed", - "rawInput": { - "prompt": "Find the current weather in Paris.", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "No alerts found." + } + } + ], + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "turnId": "child-turn-2", + "turnStatus": "completed", + "result": "No alerts found.", + "error": null + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "resume-1" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "resume-1", + "title": "Weather audit", + "status": "completed", + "rawOutput": { + "agentThreadId": "thread-paris", + "agentPath": "/root/weather_audit", + "action": "closeAgent", + "actionStatus": "completed", "senderThreadId": "thread-main", "receiverThreadIds": [ "thread-paris" ], - "agentsStates": { - "thread-paris": { - "status": "completed", - "message": null + "prompt": null, + "agentState": { + "status": "shutdown", + "message": null + } + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": "thread-paris", + "runId": "resume-1" + } + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "spawn-failed", + "kind": "other", + "title": "Start subagent", + "status": "failed", + "rawInput": { + "prompt": "Try an unavailable worker.", + "model": null, + "reasoningEffort": null + }, + "rawOutput": { + "action": "spawnAgent", + "actionStatus": "failed", + "senderThreadId": "thread-main", + "receiverThreadIds": [], + "prompt": "Try an unavailable worker.", + "agentState": null + }, + "_meta": { + "codex": { + "toolName": "Agent", + "subAgent": { + "agentThreadId": null, + "runId": "spawn-failed" } - }, - "status": "completed" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index e34f260f..8448acd4 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -139,21 +139,9 @@ "kind": "execute", "title": "ls", "status": "completed", - "content": [ - { - "type": "terminal", - "terminalId": "item-cmd-1" - } - ], "rawInput": { "command": "ls", "cwd": "/test/project" - }, - "_meta": { - "terminal_info": { - "cwd": "/test/project", - "terminal_id": "item-cmd-1" - } } } } @@ -172,17 +160,15 @@ "formatted_output": "Added.txt\nREADME.md\n", "exit_code": 0 }, - "_meta": { - "terminal_output_delta": { - "data": "Added.txt\nREADME.md\n", - "terminal_id": "item-cmd-1" - }, - "terminal_exit": { - "exit_code": 0, - "signal": null, - "terminal_id": "item-cmd-1" + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Added.txt\nREADME.md\n" + } } - } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 3fd4f914..9b647db1 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -137,7 +137,16 @@ "status": "completed", "rawOutput": { "output": "Chunk ID: search123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\nsrc/service.ts:export class Service {}\n" - } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Chunk ID: search123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\nsrc/service.ts:export class Service {}\n" + } + } + ] } } ] @@ -168,7 +177,16 @@ "status": "failed", "rawOutput": { "output": "Chunk ID: search456\nWall time: 0.0000 seconds\nProcess exited with code 1\nOutput:\n" - } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Chunk ID: search456\nWall time: 0.0000 seconds\nProcess exited with code 1\nOutput:\n" + } + } + ] } } ] @@ -204,7 +222,16 @@ "status": "completed", "rawOutput": { "output": "Chunk ID: read123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\n 1\tconsole.log('hi');\n" - } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Chunk ID: read123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\n 1\tconsole.log('hi');\n" + } + } + ] } } ] @@ -235,7 +262,16 @@ "status": "completed", "rawOutput": { "output": "Chunk ID: abc123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\nREADME.md\nsrc\n" - } + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Chunk ID: abc123\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\nREADME.md\nsrc\n" + } + } + ] } } ] diff --git a/src/__tests__/CodexACPAgent/data/portable-command-flow.json b/src/__tests__/CodexACPAgent/data/portable-command-flow.json new file mode 100644 index 00000000..bb042bc9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/portable-command-flow.json @@ -0,0 +1,102 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "portable-ok", + "kind": "execute", + "title": "echo ok", + "status": "in_progress", + "rawInput": { + "command": "echo ok", + "cwd": "/test/project" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "portable-ok", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "ok\n" + } + } + ] + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "portable-ok", + "status": "completed", + "rawOutput": { + "formatted_output": "ok\n", + "exit_code": 0 + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "portable-failed", + "kind": "execute", + "title": "false", + "status": "in_progress", + "rawInput": { + "command": "false", + "cwd": "/test/project" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "portable-failed", + "status": "failed", + "rawOutput": { + "formatted_output": "command failed\n", + "exit_code": 1 + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "command failed\n" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/response-item-portable-command.json b/src/__tests__/CodexACPAgent/data/response-item-portable-command.json new file mode 100644 index 00000000..4ae5c1ee --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/response-item-portable-command.json @@ -0,0 +1,41 @@ +[ + { + "kind": "execute", + "rawInput": { + "arguments": { + "cmd": "npm test", + "workdir": "/workspace", + "yield_time_ms": 1000, + }, + "command": "npm test", + "cwd": "/workspace", + }, + "sessionUpdate": "tool_call", + "status": "in_progress", + "title": "npm test", + "toolCallId": "call-portable", + }, + { + "content": [ + { + "content": { + "text": "Process exited with code 0 +Output: +Tests passed +", + "type": "text", + }, + "type": "content", + }, + ], + "rawOutput": { + "output": "Process exited with code 0 +Output: +Tests passed +", + }, + "sessionUpdate": "tool_call_update", + "status": "completed", + "toolCallId": "call-portable", + }, +] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts index aaeef097..f6b8d5f5 100644 --- a/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts +++ b/src/__tests__/CodexACPAgent/response-item-history-fallback.test.ts @@ -96,6 +96,16 @@ describe("ResponseItemHistoryFallback", () => { { toolCallId: "call-read-ok", status: "completed" }, ]); }); + + // Replay command history using standard ACP content. + it("recovers command history as portable content when no terminal extension is active", () => { + const updates = parseResponseItemHistoryFallback(jsonl([ + functionCall("call-portable", "npm test"), + functionCallOutput("call-portable", "Process exited with code 0\nOutput:\nTests passed\n"), + ]), "content"); + + expect(updates).toMatchFileSnapshot("data/response-item-portable-command.json"); + }); }); function jsonl(records: unknown[]): string { diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index 27461e46..d67f0caa 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -185,6 +185,115 @@ describe('CodexEventHandler - terminal output events', () => { ); }); + // Covers portable success and failure output without synthetic terminal references. + it('should report portable command output without synthetic terminals', async () => { + const portableSessionState = createTestSessionState({ + ...sessionState, + terminalOutputMode: 'content', + }); + const notifications: ServerNotification[] = [ + { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'commandExecution', + id: 'portable-ok', + command: 'echo ok', + cwd: '/test/project', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }, + { + method: 'item/commandExecution/outputDelta', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'portable-ok', + delta: 'ok\n', + }, + }, + { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'portable-ok', + command: 'echo ok', + cwd: '/test/project', + processId: 'pid-ok', + source: 'agent', + status: 'completed', + commandActions: [], + aggregatedOutput: 'ok\n', + exitCode: 0, + durationMs: 10, + }, + }, + }, + { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'commandExecution', + id: 'portable-failed', + command: 'false', + cwd: '/test/project', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }, + { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'portable-failed', + command: 'false', + cwd: '/test/project', + processId: 'pid-failed', + source: 'agent', + status: 'failed', + commandActions: [], + aggregatedOutput: 'command failed\n', + exitCode: 1, + durationMs: 10, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, portableSessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/portable-command-flow.json' + ); + }); + it('should send status update when dynamic tool call completes', async () => { const dynamicToolCompletedNotification: ServerNotification = { method: 'item/completed', diff --git a/src/__tests__/TerminalOutputMode.test.ts b/src/__tests__/TerminalOutputMode.test.ts index d2252aa5..e11e0bed 100644 --- a/src/__tests__/TerminalOutputMode.test.ts +++ b/src/__tests__/TerminalOutputMode.test.ts @@ -19,8 +19,9 @@ describe("resolveTerminalOutputMode", () => { })).toBe("terminal_output_delta"); }); - it("keeps legacy terminal_output_delta when capabilities are absent", () => { - expect(resolveTerminalOutputMode(null)).toBe("terminal_output_delta"); - expect(resolveTerminalOutputMode({})).toBe("terminal_output_delta"); + // Portable output is the default when no terminal extension is advertised. + it("uses portable content when terminal extensions are absent", () => { + expect(resolveTerminalOutputMode(null)).toBe("content"); + expect(resolveTerminalOutputMode({})).toBe("content"); }); });