diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index b3e40ce..89d2584 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -53,6 +53,7 @@ import { } from "./FastModeConfig"; import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; +import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; export interface SessionState { sessionId: string, @@ -71,6 +72,7 @@ export interface SessionState { fastModeEnabled: boolean; currentModelSupportsFast: boolean; sessionMcpServers?: Array; + terminalOutputMode: TerminalOutputMode; } interface PendingMcpStartupSession { @@ -103,6 +105,7 @@ export class CodexAcpServer implements acp.Agent { private readonly getExitCode: () => number | null; private readonly availableCommands: CodexCommands; private clientInfo: acp.Implementation | null; + private terminalOutputMode: TerminalOutputMode; private readonly sessions: Map; private readonly pendingMcpStartupSessions: Map; @@ -130,6 +133,7 @@ export class CodexAcpServer implements acp.Agent { this.defaultAuthRequest = defaultAuthRequest ?? null; this.getExitCode = getExitCode ?? (() => null); this.clientInfo = null; + this.terminalOutputMode = "terminal_output_delta"; this.availableCommands = new CodexCommands( connection, codexAcpClient, @@ -142,6 +146,7 @@ export class CodexAcpServer implements acp.Agent { ): Promise { logger.log("Initialize request received"); this.clientInfo = _params.clientInfo ?? null; + this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -353,6 +358,7 @@ export class CodexAcpServer implements acp.Agent { fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, + terminalOutputMode: this.terminalOutputMode, } this.sessions.set(sessionId, sessionState); resumeSubscribed = false; @@ -777,6 +783,7 @@ export class CodexAcpServer implements acp.Agent { fastModeEnabled: sessionMetadata.currentServiceTier === "fast", currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, + terminalOutputMode: this.terminalOutputMode, }; this.sessions.set(sessionId, sessionState); subscribed = false; diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 370f158..751ab38 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -23,6 +23,7 @@ import type { ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, ReasoningTextDeltaNotification, + TerminalInteractionNotification, ThreadGoalClearedNotification, ThreadGoalUpdatedNotification, ThreadTokenUsageUpdatedNotification, @@ -32,6 +33,7 @@ import type { import type { McpStartupCompleteEvent } from "./app-server"; import {toTokenCount} from "./TokenCount"; import { + commandExecutionUsesTerminalOutput, createCommandExecutionUpdate, createDynamicToolCallUpdate, createFileChangeUpdate, @@ -51,6 +53,7 @@ import { fuzzyFileSearchToolCallId, } from "./CodexToolCallMapper"; import { stripShellPrefix } from "./CommandUtils"; +import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode"; export { stripShellPrefix }; @@ -64,6 +67,8 @@ export class CodexEventHandler { private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); private readonly seenReasoningDeltaItemIds = new Set(); + private readonly terminalCommandIds = new Set(); + private readonly terminalCommandOutputIds = new Set(); constructor(connection: acp.AgentSideConnection, sessionState: SessionState) { this.connection = connection; @@ -165,13 +170,14 @@ export class CodexEventHandler { return this.createThreadGoalUpdatedEvent(notification.params); case "thread/goal/cleared": return this.createThreadGoalClearedEvent(notification.params); + case "item/commandExecution/terminalInteraction": + return this.createTerminalInteractionEvent(notification.params); // ignored events case "command/exec/outputDelta": case "hook/started": case "hook/completed": case "turn/diff/updated": case "turn/moderationMetadata": - case "item/commandExecution/terminalInteraction": case "item/fileChange/outputDelta": case "item/fileChange/patchUpdated": case "account/updated": @@ -324,8 +330,15 @@ export class CodexEventHandler { switch (event.item.type) { case "fileChange": return await createFileChangeUpdate(event.item); - case "commandExecution": + case "commandExecution": { + if (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); + } case "mcpToolCall": return await createMcpToolCallUpdate(event.item); case "dynamicToolCall": @@ -435,18 +448,40 @@ export class CodexEventHandler { } private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { + if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) { + this.terminalCommandOutputIds.add(event.itemId); + } + return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId)); + } + + private createCommandOutputEvent( + itemId: string, + data: string, + terminalOutputMode: TerminalOutputMode + ): UpdateSessionEvent { return { sessionUpdate: "tool_call_update", - toolCallId: event.itemId, - _meta: { - terminal_output_delta: { - data: event.delta, - terminal_id: event.itemId - } - } + toolCallId: itemId, + _meta: createTerminalOutputMeta(terminalOutputMode, itemId, data), } } + private createTerminalInteractionEvent(event: TerminalInteractionNotification): UpdateSessionEvent { + return this.createCommandOutputDeltaEvent({ + threadId: event.threadId, + turnId: event.turnId, + itemId: event.itemId, + delta: `\n${event.stdin}\n`, + }); + } + + private commandOutputMode(itemId: string): TerminalOutputMode { + if (this.sessionState.terminalOutputMode === "terminal_output" && !this.terminalCommandIds.has(itemId)) { + return "terminal_output_delta"; + } + return this.sessionState.terminalOutputMode; + } + private createMcpToolProgressEvent(event: { itemId: string, message: string }): UpdateSessionEvent { const logDelta = event.message.trim(); return { @@ -495,7 +530,7 @@ export class CodexEventHandler { } private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent { - return { + const update: UpdateSessionEvent = { sessionUpdate: "tool_call_update", toolCallId: item.id, status: item.status === "completed" ? "completed" : "failed", @@ -503,14 +538,29 @@ export class CodexEventHandler { formatted_output: item.aggregatedOutput ?? "", exit_code: item.exitCode }, - _meta: { - terminal_exit: { - exit_code: item.exitCode, - signal: null, - terminal_id: item.id - } - } + }; + + 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, + }; } private async updatePlan(event: TurnPlanUpdatedNotification): Promise { diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index d467c61..116c60b 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -34,6 +34,8 @@ type GuardianApprovalReviewNotification = | ItemGuardianApprovalReviewStartedNotification | ItemGuardianApprovalReviewCompletedNotification; type WebSearchItem = ThreadItem & { type: "webSearch" }; +type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; +type AcpToolCallEvent = Extract; function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { switch (status) { @@ -66,32 +68,23 @@ export async function createFileChangeUpdate( }; } -export async function createCommandExecutionUpdate( - item: ThreadItem & { type: "commandExecution" } -): Promise { +export async function createCommandExecutionUpdate(item: CommandExecutionItem): Promise { const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; if (commandAction) { return createCommandActionEvent(item.id, item.status, item.cwd, commandAction); } const command = stripShellPrefix(item.command); - return { + return createTerminalCommandEvent({ sessionUpdate: "tool_call", toolCallId: item.id, kind: "execute", title: command, status: toAcpStatus(item.status), - content: [{ type: "terminal", terminalId: item.id }], rawInput: { command: item.command, cwd: item.cwd, }, - _meta: { - terminal_info: { - cwd: item.cwd, - terminal_id: item.id, - }, - }, - }; + }, item.id, item.cwd); } export async function createMcpToolCallUpdate( @@ -348,50 +341,70 @@ function createCommandActionEvent( commandAction: CommandAction ): UpdateSessionEvent { const acpStatus = toAcpStatus(status); - if (commandAction.type === "read") { - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "read", - title: `Read file '${commandAction.path}'`, - locations: [{ path: commandAction.path }], - }; - } else if (commandAction.type === "search") { - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "search", - title: createSearchTitle(commandAction.query, commandAction.path), - }; - } else if (commandAction.type === "listFiles") { - const title = commandAction.path - ? `List files in '${commandAction.path}'` - : "List files"; - return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "read", - title: title, - }; + switch (commandAction.type) { + case "read": + return { + sessionUpdate: "tool_call", + toolCallId: id, + status: acpStatus, + kind: "read", + title: `Read file '${commandAction.path}'`, + locations: [{ path: commandAction.path }], + }; + case "search": + return { + sessionUpdate: "tool_call", + toolCallId: id, + status: acpStatus, + kind: "search", + title: createSearchTitle(commandAction.query, commandAction.path), + }; + case "listFiles": { + const title = commandAction.path + ? `List files in '${commandAction.path}'` + : "List files"; + return { + sessionUpdate: "tool_call", + toolCallId: id, + status: acpStatus, + kind: "read", + title: title, + }; + } + case "unknown": + return createTerminalCommandEvent({ + sessionUpdate: "tool_call", + toolCallId: id, + status: acpStatus, + kind: "execute", + title: stripShellPrefix(commandAction.command), + rawInput: { + command: commandAction.command, + cwd, + }, + }, id, cwd); } +} + +export function commandExecutionUsesTerminalOutput(item: CommandExecutionItem): boolean { + const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; + return commandAction === undefined || commandAction.type === "unknown"; +} + +function createTerminalCommandEvent( + event: AcpToolCallEvent, + terminalId: string, + cwd: string, +): UpdateSessionEvent { + const { rawInput, ...eventWithoutRawInput } = event; return { - sessionUpdate: "tool_call", - toolCallId: id, - status: acpStatus, - kind: "execute", - title: stripShellPrefix(commandAction.command), - content: [{ type: "terminal", terminalId: id }], - rawInput: { - command: commandAction.command, - cwd, - }, + ...eventWithoutRawInput, + content: [{ type: "terminal", terminalId }], + ...(rawInput === undefined ? {} : { rawInput }), _meta: { terminal_info: { cwd, - terminal_id: id, + terminal_id: terminalId, }, }, }; diff --git a/src/TerminalOutputMode.ts b/src/TerminalOutputMode.ts new file mode 100644 index 0000000..a04b62d --- /dev/null +++ b/src/TerminalOutputMode.ts @@ -0,0 +1,36 @@ +import type * as acp from "@agentclientprotocol/sdk"; + +export type TerminalOutputMode = "terminal_output" | "terminal_output_delta"; + +export function resolveTerminalOutputMode( + clientCapabilities?: acp.ClientCapabilities | null +): TerminalOutputMode { + const meta = clientCapabilities?._meta; + if (meta?.["terminal_output"] === true) { + return "terminal_output"; + } + return "terminal_output_delta"; +} + +export function createTerminalOutputMeta( + mode: TerminalOutputMode, + terminalId: string, + data: string +): Record { + switch (mode) { + case "terminal_output": + return { + terminal_output: { + data, + terminal_id: terminalId, + }, + }; + case "terminal_output_delta": + return { + terminal_output_delta: { + data, + terminal_id: terminalId, + }, + }; + } +} diff --git a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json index c0352fc..a8fa6f4 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json +++ b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json @@ -10,13 +10,6 @@ "rawOutput": { "formatted_output": "file1.txt\nfile2.txt\nfile3.txt\n", "exit_code": 0 - }, - "_meta": { - "terminal_exit": { - "exit_code": 0, - "signal": null, - "terminal_id": "command-123" - } } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-command-failed.json b/src/__tests__/CodexACPAgent/data/terminal-command-failed.json index fa45f58..63c25d8 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-command-failed.json +++ b/src/__tests__/CodexACPAgent/data/terminal-command-failed.json @@ -10,13 +10,6 @@ "rawOutput": { "formatted_output": "cat: nonexistent.txt: No such file or directory", "exit_code": 1 - }, - "_meta": { - "terminal_exit": { - "exit_code": 1, - "signal": null, - "terminal_id": "command-456" - } } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json b/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json new file mode 100644 index 0000000..e8e1dcf --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-interaction-stdin.json @@ -0,0 +1,18 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-123", + "_meta": { + "terminal_output_delta": { + "data": "\ncontinue\n", + "terminal_id": "command-123" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/terminal-output-completion-fallback.json b/src/__tests__/CodexACPAgent/data/terminal-output-completion-fallback.json new file mode 100644 index 0000000..e8be400 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-output-completion-fallback.json @@ -0,0 +1,59 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "command-terminal-output-completion", + "kind": "execute", + "title": "git status --short", + "status": "in_progress", + "content": [ + { + "type": "terminal", + "terminalId": "command-terminal-output-completion" + } + ], + "rawInput": { + "command": "git status --short", + "cwd": "/test/project" + }, + "_meta": { + "terminal_info": { + "cwd": "/test/project", + "terminal_id": "command-terminal-output-completion" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-terminal-output-completion", + "status": "completed", + "rawOutput": { + "formatted_output": "M src/CodexEventHandler.ts\n", + "exit_code": 0 + }, + "_meta": { + "terminal_output": { + "data": "M src/CodexEventHandler.ts\n", + "terminal_id": "command-terminal-output-completion" + }, + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "command-terminal-output-completion" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/terminal-output-meta-flow.json b/src/__tests__/CodexACPAgent/data/terminal-output-meta-flow.json new file mode 100644 index 0000000..d9056f4 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-output-meta-flow.json @@ -0,0 +1,91 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "command-terminal-output", + "kind": "execute", + "title": "python manage.py migrate", + "status": "in_progress", + "content": [ + { + "type": "terminal", + "terminalId": "command-terminal-output" + } + ], + "rawInput": { + "command": "python manage.py migrate", + "cwd": "/test/project" + }, + "_meta": { + "terminal_info": { + "cwd": "/test/project", + "terminal_id": "command-terminal-output" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-terminal-output", + "_meta": { + "terminal_output": { + "data": "Applying migrations\n", + "terminal_id": "command-terminal-output" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-terminal-output", + "_meta": { + "terminal_output": { + "data": "\nyes\n", + "terminal_id": "command-terminal-output" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-terminal-output", + "status": "completed", + "rawOutput": { + "formatted_output": "Applying migrations\n\nyes\nDone\n", + "exit_code": 0 + }, + "_meta": { + "terminal_exit": { + "exit_code": 0, + "signal": null, + "terminal_id": "command-terminal-output" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json b/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json new file mode 100644 index 0000000..7f49727 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/terminal-output-parsed-command-legacy-delta.json @@ -0,0 +1,55 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "command-read-file", + "status": "in_progress", + "kind": "read", + "title": "Read file '/test/project/README.md'", + "locations": [ + { + "path": "/test/project/README.md" + } + ] + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-read-file", + "_meta": { + "terminal_output_delta": { + "data": "# Project\n", + "terminal_id": "command-read-file" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "command-read-file", + "status": "completed", + "rawOutput": { + "formatted_output": "# Project\n", + "exit_code": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index 5546a1a..27461e4 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -106,6 +106,25 @@ describe('CodexEventHandler - terminal output events', () => { ); }); + it('should stream terminal interaction stdin as terminal output delta', async () => { + const terminalInteractionNotification: ServerNotification = { + method: 'item/commandExecution/terminalInteraction', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'command-123', + processId: 'pid-456', + stdin: 'continue', + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [terminalInteractionNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/terminal-interaction-stdin.json' + ); + }); + it('should send formatted output on command completion', async () => { const commandCompletedNotification: ServerNotification = { method: 'item/completed', @@ -260,6 +279,231 @@ describe('CodexEventHandler - terminal output events', () => { ); }); + it('should use terminal_output meta when supported', async () => { + const terminalOutputSessionState = createTestSessionState({ + sessionId, + currentModelId: 'model-id[effort]', + agentMode: AgentMode.DEFAULT_AGENT_MODE, + terminalOutputMode: 'terminal_output', + }); + const commandStartNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-terminal-output', + command: 'python manage.py migrate', + cwd: '/test/project', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }; + const outputDeltaNotification: ServerNotification = { + method: 'item/commandExecution/outputDelta', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'command-terminal-output', + delta: 'Applying migrations\n', + }, + }; + const terminalInteractionNotification: ServerNotification = { + method: 'item/commandExecution/terminalInteraction', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'command-terminal-output', + processId: 'pid-456', + stdin: 'yes', + }, + }; + const commandCompletedNotification: ServerNotification = { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-terminal-output', + command: 'python manage.py migrate', + cwd: '/test/project', + processId: 'pid-456', + source: 'agent', + status: 'completed', + commandActions: [], + aggregatedOutput: 'Applying migrations\n\nyes\nDone\n', + exitCode: 0, + durationMs: 250, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, terminalOutputSessionState, [ + commandStartNotification, + outputDeltaNotification, + terminalInteractionNotification, + commandCompletedNotification + ]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/terminal-output-meta-flow.json' + ); + }); + + it('should flush aggregated output when terminal_output command completes without deltas', async () => { + const terminalOutputSessionState = createTestSessionState({ + sessionId, + currentModelId: 'model-id[effort]', + agentMode: AgentMode.DEFAULT_AGENT_MODE, + terminalOutputMode: 'terminal_output', + }); + const commandStartNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-terminal-output-completion', + command: 'git status --short', + cwd: '/test/project', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }; + const commandCompletedNotification: ServerNotification = { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-terminal-output-completion', + command: 'git status --short', + cwd: '/test/project', + processId: 'pid-456', + source: 'agent', + status: 'completed', + commandActions: [], + aggregatedOutput: 'M src/CodexEventHandler.ts\n', + exitCode: 0, + durationMs: 25, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, terminalOutputSessionState, [ + commandStartNotification, + commandCompletedNotification + ]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/terminal-output-completion-fallback.json' + ); + }); + + it('should keep parsed non-terminal command output on legacy delta metadata', async () => { + const terminalOutputSessionState = createTestSessionState({ + sessionId, + currentModelId: 'model-id[effort]', + agentMode: AgentMode.DEFAULT_AGENT_MODE, + terminalOutputMode: 'terminal_output', + }); + const commandStartNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-read-file', + command: 'cat README.md', + cwd: '/test/project', + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [ + { + type: 'read', + command: 'cat README.md', + name: 'cat', + path: '/test/project/README.md', + }, + ], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }; + const outputDeltaNotification: ServerNotification = { + method: 'item/commandExecution/outputDelta', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'command-read-file', + delta: '# Project\n', + }, + }; + const commandCompletedNotification: ServerNotification = { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-read-file', + command: 'cat README.md', + cwd: '/test/project', + processId: 'pid-456', + source: 'agent', + status: 'completed', + commandActions: [ + { + type: 'read', + command: 'cat README.md', + name: 'cat', + path: '/test/project/README.md', + }, + ], + aggregatedOutput: '# Project\n', + exitCode: 0, + durationMs: 10, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, terminalOutputSessionState, [ + commandStartNotification, + outputDeltaNotification, + commandCompletedNotification + ]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/terminal-output-parsed-command-legacy-delta.json' + ); + }); + it('should stream multiple terminal output deltas without accumulation', async () => { const delta1: ServerNotification = { method: 'item/commandExecution/outputDelta', diff --git a/src/__tests__/TerminalOutputMode.test.ts b/src/__tests__/TerminalOutputMode.test.ts new file mode 100644 index 0000000..d2252aa --- /dev/null +++ b/src/__tests__/TerminalOutputMode.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveTerminalOutputMode } from "../TerminalOutputMode"; + +describe("resolveTerminalOutputMode", () => { + it("uses terminal_output when advertised", () => { + expect(resolveTerminalOutputMode({ + _meta: { + terminal_output: true, + terminal_output_delta: true, + }, + })).toBe("terminal_output"); + }); + + it("uses legacy terminal_output_delta when only it is advertised", () => { + expect(resolveTerminalOutputMode({ + _meta: { + terminal_output_delta: true, + }, + })).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"); + }); +}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 32b7b8e..27f3bcd 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -346,6 +346,7 @@ export function createTestSessionState(overrides?: Partial): Sessi agentMode: AgentMode.DEFAULT_AGENT_MODE, fastModeEnabled: false, currentModelSupportsFast: false, + terminalOutputMode: "terminal_output_delta", ...overrides, }; }