diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 694c44df..24a97001 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -105,7 +105,11 @@ import { } from "./FastModeConfig"; import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; -import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode"; +import { + clientSupportsTerminalOutputDelta, + resolveTerminalOutputMode, + type TerminalOutputMode, +} from "./TerminalOutputMode"; import {clientSupportsPlanUpdates} from "./PlanCapabilities"; import { createAgentTextMessageChunk, @@ -181,6 +185,7 @@ export interface SessionState { currentModelSupportsFast: boolean; sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; + terminalOutputDeltaSupported: boolean; currentGoal?: ThreadGoalSnapshot | null; goalRevision: number; sessionTitle: string | null; @@ -269,6 +274,7 @@ export class CodexAcpServer { private clientInfo: acp.Implementation | null; private clientCapabilities: acp.ClientCapabilities | null; private terminalOutputMode: TerminalOutputMode; + private terminalOutputDeltaSupported: boolean; private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; @@ -317,6 +323,7 @@ export class CodexAcpServer { this.clientInfo = null; this.clientCapabilities = null; this.terminalOutputMode = "terminal_output_delta"; + this.terminalOutputDeltaSupported = false; this.booleanConfigOptionsSupported = false; this.currentAuthStatus = null; this.availableCommands = this.createAvailableCommands(codexAcpClient); @@ -340,6 +347,7 @@ export class CodexAcpServer { this.clientCapabilities = _params.clientCapabilities ?? null; this.initializeRequest = _params; this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities); + this.terminalOutputDeltaSupported = clientSupportsTerminalOutputDelta(_params.clientCapabilities); this.booleanConfigOptionsSupported = clientSupportsBooleanConfigOptions(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); this.publishFirstAuthStatusAfterResponse(); @@ -688,6 +696,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + terminalOutputDeltaSupported: this.terminalOutputDeltaSupported, goalRevision: 0, sessionTitle: null, sessionTitleSource: operation === "resume" ? "unknown" : "unset", @@ -1946,6 +1955,7 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + terminalOutputDeltaSupported: this.terminalOutputDeltaSupported, goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index b6b2275f..168f6163 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -226,7 +226,7 @@ export class CodexEventHandler { private disposed = false; private readonly seenReasoningDeltaItemIds = new Set(); private readonly terminalCommandIds = new Set(); - private readonly terminalCommandOutputIds = new Set(); + private readonly commandOutputIds = new Set(); private readonly agentMessagePhases = new Map(); private readonly turnDiffs = new Map(); private readonly oversizedTurnDiffs = new Set(); @@ -807,7 +807,7 @@ export class CodexEventHandler { this.terminalCommandIds.add(event.item.id); } else { this.terminalCommandIds.delete(event.item.id); - this.terminalCommandOutputIds.delete(event.item.id); + this.commandOutputIds.delete(event.item.id); } return await createCommandExecutionUpdate(event.item); } @@ -1022,8 +1022,8 @@ export class CodexEventHandler { } private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { - if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) { - this.terminalCommandOutputIds.add(event.itemId); + if (event.delta.length > 0) { + this.commandOutputIds.add(event.itemId); } return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId)); } @@ -1041,12 +1041,11 @@ export class CodexEventHandler { } private createTerminalInteractionEvent(event: TerminalInteractionNotification): UpdateSessionEvent { - return this.createCommandOutputDeltaEvent({ - threadId: event.threadId, - turnId: event.turnId, - itemId: event.itemId, - delta: `\n${event.stdin}\n`, - }); + return this.createCommandOutputEvent( + event.itemId, + `\n${event.stdin}\n`, + this.commandOutputMode(event.itemId), + ); } private commandOutputMode(itemId: string): TerminalOutputMode { @@ -1110,29 +1109,34 @@ export class CodexEventHandler { toolCallId: item.id, ...(name === undefined ? {} : {name}), status: item.status === "completed" ? "completed" : "failed", - rawOutput: { - formatted_output: item.aggregatedOutput ?? "", - exit_code: item.exitCode - }, + ...(this.sessionState.terminalOutputDeltaSupported ? {} : { + 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 commandHadOutput = this.commandOutputIds.delete(item.id); const terminalMeta: Record = {}; - if (!commandHadOutput && item.aggregatedOutput) { + if (!commandHadOutput && item.aggregatedOutput && + (commandHadTerminal || this.sessionState.terminalOutputDeltaSupported)) { Object.assign( terminalMeta, createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput) ); } - terminalMeta["terminal_exit"] = { - exit_code: item.exitCode, - signal: null, - terminal_id: item.id - }; + if (commandHadTerminal) { + terminalMeta["terminal_exit"] = { + exit_code: item.exitCode, + signal: null, + terminal_id: item.id + }; + } + if (Object.keys(terminalMeta).length === 0) { + return update; + } return { ...update, _meta: terminalMeta, diff --git a/src/TerminalOutputMode.ts b/src/TerminalOutputMode.ts index a04b62db..610f902b 100644 --- a/src/TerminalOutputMode.ts +++ b/src/TerminalOutputMode.ts @@ -6,12 +6,21 @@ export function resolveTerminalOutputMode( clientCapabilities?: acp.ClientCapabilities | null ): TerminalOutputMode { const meta = clientCapabilities?._meta; + if (meta?.["terminal_output_delta"] === true) { + return "terminal_output_delta"; + } if (meta?.["terminal_output"] === true) { return "terminal_output"; } return "terminal_output_delta"; } +export function clientSupportsTerminalOutputDelta( + clientCapabilities?: acp.ClientCapabilities | null +): boolean { + return clientCapabilities?._meta?.["terminal_output_delta"] === true; +} + export function createTerminalOutputMeta( mode: TerminalOutputMode, terminalId: string, diff --git a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json index a8fa6f4f..27d6d1cf 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-command-completed.json +++ b/src/__tests__/CodexACPAgent/data/terminal-command-completed.json @@ -7,9 +7,11 @@ "sessionUpdate": "tool_call_update", "toolCallId": "command-123", "status": "completed", - "rawOutput": { - "formatted_output": "file1.txt\nfile2.txt\nfile3.txt\n", - "exit_code": 0 + "_meta": { + "terminal_output_delta": { + "data": "file1.txt\nfile2.txt\nfile3.txt\n", + "terminal_id": "command-123" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-full-flow.json b/src/__tests__/CodexACPAgent/data/terminal-full-flow.json index d69f63eb..318d22bb 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-full-flow.json +++ b/src/__tests__/CodexACPAgent/data/terminal-full-flow.json @@ -56,10 +56,6 @@ "sessionUpdate": "tool_call_update", "toolCallId": "command-flow", "status": "completed", - "rawOutput": { - "formatted_output": "hello\n", - "exit_code": 0 - }, "_meta": { "terminal_exit": { "exit_code": 0, diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index d316659d..0390035b 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -129,7 +129,11 @@ describe('CodexEventHandler - terminal output events', () => { ); }); - it('should send formatted output on command completion', async () => { + it('should send one delta when command completion has no streamed output', async () => { + const deltaSessionState = createTestSessionState({ + sessionId, + terminalOutputDeltaSupported: true, + }); const commandCompletedNotification: ServerNotification = { method: 'item/completed', params: { @@ -154,13 +158,64 @@ describe('CodexEventHandler - terminal output events', () => { }, }; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [commandCompletedNotification]); + await setupPromptAndSendNotifications(mockFixture, sessionId, deltaSessionState, [commandCompletedNotification]); await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( 'data/terminal-command-completed.json' ); }); + it('should flush aggregated output after terminal input without streamed output', async () => { + const deltaSessionState = createTestSessionState({ + sessionId, + terminalOutputDeltaSupported: true, + }); + const terminalInteractionNotification: ServerNotification = { + method: 'item/commandExecution/terminalInteraction', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'command-123', + processId: 'pid-456', + stdin: 'continue', + }, + }; + const commandCompletedNotification: ServerNotification = { + method: 'item/completed', + params: { + threadId: sessionId, + turnId: 'turn-1', + completedAtMs: 0, + item: { + type: 'commandExecution', + id: 'command-123', + pluginId: null, + scriptPath: null, + command: 'read answer; echo done', + cwd: '/test/project', + processId: 'pid-456', + source: 'agent', + status: 'completed', + commandActions: [], + aggregatedOutput: 'done\n', + exitCode: 0, + durationMs: 150, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, deltaSessionState, [ + terminalInteractionNotification, + commandCompletedNotification, + ]); + + const updates = mockFixture.getAcpConnectionEvents([]).map(event => event.args[0].update); + expect(updates).toHaveLength(2); + expect(updates[0]._meta?.terminal_output_delta?.data).toBe('\ncontinue\n'); + expect(updates[1]._meta?.terminal_output_delta?.data).toBe('done\n'); + expect(updates[1]).not.toHaveProperty('rawOutput'); + }); + it('should handle failed command completion', async () => { const commandFailedNotification: ServerNotification = { method: 'item/completed', @@ -222,6 +277,10 @@ describe('CodexEventHandler - terminal output events', () => { }); it('should handle full terminal output flow: start -> delta -> complete', async () => { + const deltaSessionState = createTestSessionState({ + sessionId, + terminalOutputDeltaSupported: true, + }); const commandStartNotification: ServerNotification = { method: 'item/started', params: { @@ -280,7 +339,7 @@ describe('CodexEventHandler - terminal output events', () => { }, }; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [ + await setupPromptAndSendNotifications(mockFixture, sessionId, deltaSessionState, [ commandStartNotification, outputDeltaNotification, commandCompletedNotification diff --git a/src/__tests__/TerminalOutputMode.test.ts b/src/__tests__/TerminalOutputMode.test.ts index d2252aa5..79beddf4 100644 --- a/src/__tests__/TerminalOutputMode.test.ts +++ b/src/__tests__/TerminalOutputMode.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest"; import { resolveTerminalOutputMode } from "../TerminalOutputMode"; describe("resolveTerminalOutputMode", () => { - it("uses terminal_output when advertised", () => { + it("prefers terminal_output_delta when both modes are advertised", () => { expect(resolveTerminalOutputMode({ _meta: { terminal_output: true, terminal_output_delta: true, }, - })).toBe("terminal_output"); + })).toBe("terminal_output_delta"); }); it("uses legacy terminal_output_delta when only it is advertised", () => { @@ -19,6 +19,14 @@ describe("resolveTerminalOutputMode", () => { })).toBe("terminal_output_delta"); }); + it("uses terminal_output when it is the only advertised mode", () => { + expect(resolveTerminalOutputMode({ + _meta: { + terminal_output: true, + }, + })).toBe("terminal_output"); + }); + 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 d522b974..d613be8b 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -419,6 +419,7 @@ export function createTestSessionState(overrides?: Partial): Sessi fastModeEnabled: false, currentModelSupportsFast: false, terminalOutputMode: "terminal_output_delta", + terminalOutputDeltaSupported: false, goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown",