Skip to content

Commit db13783

Browse files
committed
Negotiate terminal output metadata mode
Support both _meta types. Closes #166
1 parent 1d76251 commit db13783

13 files changed

Lines changed: 668 additions & 82 deletions

src/CodexAcpServer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from "./FastModeConfig";
5151
import packageJson from "../package.json";
5252
import {isJetBrains2026_1Client} from "./JBUtils";
53+
import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode";
5354

5455
export interface SessionState {
5556
sessionId: string,
@@ -68,6 +69,7 @@ export interface SessionState {
6869
fastModeEnabled: boolean;
6970
currentModelSupportsFast: boolean;
7071
sessionMcpServers?: Array<string>;
72+
terminalOutputMode: TerminalOutputMode;
7173
}
7274

7375
interface PendingMcpStartupSession {
@@ -100,6 +102,7 @@ export class CodexAcpServer implements acp.Agent {
100102
private readonly getExitCode: () => number | null;
101103
private readonly availableCommands: CodexCommands;
102104
private clientInfo: acp.Implementation | null;
105+
private terminalOutputMode: TerminalOutputMode;
103106

104107
private readonly sessions: Map<string, SessionState>;
105108
private readonly pendingMcpStartupSessions: Map<string, PendingMcpStartupSession>;
@@ -127,6 +130,7 @@ export class CodexAcpServer implements acp.Agent {
127130
this.defaultAuthRequest = defaultAuthRequest ?? null;
128131
this.getExitCode = getExitCode ?? (() => null);
129132
this.clientInfo = null;
133+
this.terminalOutputMode = "terminal_output_delta";
130134
this.availableCommands = new CodexCommands(
131135
connection,
132136
codexAcpClient,
@@ -139,6 +143,7 @@ export class CodexAcpServer implements acp.Agent {
139143
): Promise<acp.InitializeResponse> {
140144
logger.log("Initialize request received");
141145
this.clientInfo = _params.clientInfo ?? null;
146+
this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities);
142147
await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params));
143148
return {
144149
protocolVersion: acp.PROTOCOL_VERSION,
@@ -349,6 +354,7 @@ export class CodexAcpServer implements acp.Agent {
349354
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
350355
currentModelSupportsFast: currentModelSupportsFast,
351356
sessionMcpServers: sessionMcpServers,
357+
terminalOutputMode: this.terminalOutputMode,
352358
}
353359
this.sessions.set(sessionId, sessionState);
354360
resumeSubscribed = false;
@@ -738,6 +744,7 @@ export class CodexAcpServer implements acp.Agent {
738744
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
739745
currentModelSupportsFast: currentModelSupportsFast,
740746
sessionMcpServers: sessionMcpServers,
747+
terminalOutputMode: this.terminalOutputMode,
741748
};
742749
this.sessions.set(sessionId, sessionState);
743750
subscribed = false;

src/CodexEventHandler.ts

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import type {
1818
ItemCompletedNotification,
1919
ItemStartedNotification, ThreadItem,
2020
ModelReroutedNotification,
21+
TerminalInteractionNotification,
2122
ThreadTokenUsageUpdatedNotification,
2223
TurnPlanUpdatedNotification,
2324
WarningNotification
2425
} from "./app-server/v2";
2526
import type { McpStartupCompleteEvent } from "./app-server";
2627
import {toTokenCount} from "./TokenCount";
2728
import {
29+
commandExecutionUsesTerminalOutput,
2830
createCommandExecutionUpdate,
2931
createDynamicToolCallUpdate,
3032
createFileChangeUpdate,
@@ -36,6 +38,7 @@ import {
3638
fuzzyFileSearchToolCallId,
3739
} from "./CodexToolCallMapper";
3840
import { stripShellPrefix } from "./CommandUtils";
41+
import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode";
3942

4043
export { stripShellPrefix };
4144

@@ -45,6 +48,8 @@ export class CodexEventHandler {
4548
private readonly sessionState: SessionState;
4649
private failure: RequestError | null = null;
4750
private readonly activeFuzzyFileSearchSessions = new Set<string>();
51+
private readonly terminalCommandIds = new Set<string>();
52+
private readonly terminalCommandOutputIds = new Set<string>();
4853

4954
constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
5055
this.connection = connection;
@@ -138,6 +143,8 @@ export class CodexEventHandler {
138143
return this.handleFuzzyFileSearchSessionUpdated(notification.params);
139144
case "fuzzyFileSearch/sessionCompleted":
140145
return this.handleFuzzyFileSearchSessionCompleted(notification.params);
146+
case "item/commandExecution/terminalInteraction":
147+
return this.createTerminalInteractionEvent(notification.params);
141148
// ignored events
142149
case "command/exec/outputDelta":
143150
case "item/autoApprovalReview/started":
@@ -148,7 +155,6 @@ export class CodexEventHandler {
148155
case "item/reasoning/summaryPartAdded":
149156
case "item/reasoning/textDelta":
150157
case "turn/diff/updated":
151-
case "item/commandExecution/terminalInteraction":
152158
case "item/fileChange/outputDelta":
153159
case "item/fileChange/patchUpdated":
154160
case "account/updated":
@@ -249,8 +255,15 @@ export class CodexEventHandler {
249255
switch (event.item.type) {
250256
case "fileChange":
251257
return await createFileChangeUpdate(event.item);
252-
case "commandExecution":
258+
case "commandExecution": {
259+
if (commandExecutionUsesTerminalOutput(event.item)) {
260+
this.terminalCommandIds.add(event.item.id);
261+
} else {
262+
this.terminalCommandIds.delete(event.item.id);
263+
this.terminalCommandOutputIds.delete(event.item.id);
264+
}
253265
return await createCommandExecutionUpdate(event.item);
266+
}
254267
case "mcpToolCall":
255268
return await createMcpToolCallUpdate(event.item);
256269
case "dynamicToolCall":
@@ -316,18 +329,40 @@ export class CodexEventHandler {
316329
}
317330

318331
private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent {
332+
if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) {
333+
this.terminalCommandOutputIds.add(event.itemId);
334+
}
335+
return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId));
336+
}
337+
338+
private createCommandOutputEvent(
339+
itemId: string,
340+
data: string,
341+
terminalOutputMode: TerminalOutputMode
342+
): UpdateSessionEvent {
319343
return {
320344
sessionUpdate: "tool_call_update",
321-
toolCallId: event.itemId,
322-
_meta: {
323-
terminal_output_delta: {
324-
data: event.delta,
325-
terminal_id: event.itemId
326-
}
327-
}
345+
toolCallId: itemId,
346+
_meta: createTerminalOutputMeta(terminalOutputMode, itemId, data),
328347
}
329348
}
330349

350+
private createTerminalInteractionEvent(event: TerminalInteractionNotification): UpdateSessionEvent {
351+
return this.createCommandOutputDeltaEvent({
352+
threadId: event.threadId,
353+
turnId: event.turnId,
354+
itemId: event.itemId,
355+
delta: `\n${event.stdin}\n`,
356+
});
357+
}
358+
359+
private commandOutputMode(itemId: string): TerminalOutputMode {
360+
if (this.sessionState.terminalOutputMode === "terminal_output" && !this.terminalCommandIds.has(itemId)) {
361+
return "terminal_output_delta";
362+
}
363+
return this.sessionState.terminalOutputMode;
364+
}
365+
331366
private createMcpToolProgressEvent(event: { itemId: string, message: string }): UpdateSessionEvent {
332367
const logDelta = event.message.trim();
333368
return {
@@ -376,22 +411,37 @@ export class CodexEventHandler {
376411
}
377412

378413
private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent {
379-
return {
414+
const update: UpdateSessionEvent = {
380415
sessionUpdate: "tool_call_update",
381416
toolCallId: item.id,
382417
status: item.status === "completed" ? "completed" : "failed",
383418
rawOutput: {
384419
formatted_output: item.aggregatedOutput ?? "",
385420
exit_code: item.exitCode
386421
},
387-
_meta: {
388-
terminal_exit: {
389-
exit_code: item.exitCode,
390-
signal: null,
391-
terminal_id: item.id
392-
}
393-
}
422+
};
423+
424+
const commandHadTerminal = this.terminalCommandIds.delete(item.id);
425+
const commandHadOutput = this.terminalCommandOutputIds.delete(item.id);
426+
if (!commandHadTerminal) {
427+
return update;
428+
}
429+
const terminalMeta: Record<string, unknown> = {};
430+
if (!commandHadOutput && item.aggregatedOutput) {
431+
Object.assign(
432+
terminalMeta,
433+
createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput)
434+
);
394435
}
436+
terminalMeta["terminal_exit"] = {
437+
exit_code: item.exitCode,
438+
signal: null,
439+
terminal_id: item.id
440+
};
441+
return {
442+
...update,
443+
_meta: terminalMeta,
444+
};
395445
}
396446

397447
private async updatePlan(event: TurnPlanUpdatedNotification): Promise<UpdateSessionEvent> {

src/CodexToolCallMapper.ts

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {logger} from "./Logger";
2424

2525
type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus;
2626
type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
27+
type CommandExecutionItem = ThreadItem & { type: "commandExecution" };
28+
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;
2729

2830
function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
2931
switch (status) {
@@ -56,32 +58,23 @@ export async function createFileChangeUpdate(
5658
};
5759
}
5860

59-
export async function createCommandExecutionUpdate(
60-
item: ThreadItem & { type: "commandExecution" }
61-
): Promise<UpdateSessionEvent> {
61+
export async function createCommandExecutionUpdate(item: CommandExecutionItem): Promise<UpdateSessionEvent> {
6262
const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined;
6363
if (commandAction) {
6464
return createCommandActionEvent(item.id, item.status, item.cwd, commandAction);
6565
}
6666
const command = stripShellPrefix(item.command);
67-
return {
67+
return createTerminalCommandEvent({
6868
sessionUpdate: "tool_call",
6969
toolCallId: item.id,
7070
kind: "execute",
7171
title: command,
7272
status: toAcpStatus(item.status),
73-
content: [{ type: "terminal", terminalId: item.id }],
7473
rawInput: {
7574
command: item.command,
7675
cwd: item.cwd,
7776
},
78-
_meta: {
79-
terminal_info: {
80-
cwd: item.cwd,
81-
terminal_id: item.id,
82-
},
83-
},
84-
};
77+
}, item.id, item.cwd);
8578
}
8679

8780
export async function createMcpToolCallUpdate(
@@ -197,50 +190,70 @@ function createCommandActionEvent(
197190
commandAction: CommandAction
198191
): UpdateSessionEvent {
199192
const acpStatus = toAcpStatus(status);
200-
if (commandAction.type === "read") {
201-
return {
202-
sessionUpdate: "tool_call",
203-
toolCallId: id,
204-
status: acpStatus,
205-
kind: "read",
206-
title: `Read file '${commandAction.path}'`,
207-
locations: [{ path: commandAction.path }],
208-
};
209-
} else if (commandAction.type === "search") {
210-
return {
211-
sessionUpdate: "tool_call",
212-
toolCallId: id,
213-
status: acpStatus,
214-
kind: "search",
215-
title: createSearchTitle(commandAction.query, commandAction.path),
216-
};
217-
} else if (commandAction.type === "listFiles") {
218-
const title = commandAction.path
219-
? `List files in '${commandAction.path}'`
220-
: "List files";
221-
return {
222-
sessionUpdate: "tool_call",
223-
toolCallId: id,
224-
status: acpStatus,
225-
kind: "read",
226-
title: title,
227-
};
193+
switch (commandAction.type) {
194+
case "read":
195+
return {
196+
sessionUpdate: "tool_call",
197+
toolCallId: id,
198+
status: acpStatus,
199+
kind: "read",
200+
title: `Read file '${commandAction.path}'`,
201+
locations: [{ path: commandAction.path }],
202+
};
203+
case "search":
204+
return {
205+
sessionUpdate: "tool_call",
206+
toolCallId: id,
207+
status: acpStatus,
208+
kind: "search",
209+
title: createSearchTitle(commandAction.query, commandAction.path),
210+
};
211+
case "listFiles": {
212+
const title = commandAction.path
213+
? `List files in '${commandAction.path}'`
214+
: "List files";
215+
return {
216+
sessionUpdate: "tool_call",
217+
toolCallId: id,
218+
status: acpStatus,
219+
kind: "read",
220+
title: title,
221+
};
222+
}
223+
case "unknown":
224+
return createTerminalCommandEvent({
225+
sessionUpdate: "tool_call",
226+
toolCallId: id,
227+
status: acpStatus,
228+
kind: "execute",
229+
title: stripShellPrefix(commandAction.command),
230+
rawInput: {
231+
command: commandAction.command,
232+
cwd,
233+
},
234+
}, id, cwd);
228235
}
236+
}
237+
238+
export function commandExecutionUsesTerminalOutput(item: CommandExecutionItem): boolean {
239+
const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined;
240+
return commandAction === undefined || commandAction.type === "unknown";
241+
}
242+
243+
function createTerminalCommandEvent(
244+
event: AcpToolCallEvent,
245+
terminalId: string,
246+
cwd: string,
247+
): UpdateSessionEvent {
248+
const { rawInput, ...eventWithoutRawInput } = event;
229249
return {
230-
sessionUpdate: "tool_call",
231-
toolCallId: id,
232-
status: acpStatus,
233-
kind: "execute",
234-
title: stripShellPrefix(commandAction.command),
235-
content: [{ type: "terminal", terminalId: id }],
236-
rawInput: {
237-
command: commandAction.command,
238-
cwd,
239-
},
250+
...eventWithoutRawInput,
251+
content: [{ type: "terminal", terminalId }],
252+
...(rawInput === undefined ? {} : { rawInput }),
240253
_meta: {
241254
terminal_info: {
242255
cwd,
243-
terminal_id: id,
256+
terminal_id: terminalId,
244257
},
245258
},
246259
};

0 commit comments

Comments
 (0)