Skip to content

Commit eda7d6f

Browse files
authored
Negotiate terminal output metadata mode (#187)
Support both _meta types. Closes #166
1 parent e92c292 commit eda7d6f

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
@@ -53,6 +53,7 @@ import {
5353
} from "./FastModeConfig";
5454
import packageJson from "../package.json";
5555
import {isJetBrains2026_1Client} from "./JBUtils";
56+
import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode";
5657

5758
export interface SessionState {
5859
sessionId: string,
@@ -71,6 +72,7 @@ export interface SessionState {
7172
fastModeEnabled: boolean;
7273
currentModelSupportsFast: boolean;
7374
sessionMcpServers?: Array<string>;
75+
terminalOutputMode: TerminalOutputMode;
7476
}
7577

7678
interface PendingMcpStartupSession {
@@ -103,6 +105,7 @@ export class CodexAcpServer implements acp.Agent {
103105
private readonly getExitCode: () => number | null;
104106
private readonly availableCommands: CodexCommands;
105107
private clientInfo: acp.Implementation | null;
108+
private terminalOutputMode: TerminalOutputMode;
106109

107110
private readonly sessions: Map<string, SessionState>;
108111
private readonly pendingMcpStartupSessions: Map<string, PendingMcpStartupSession>;
@@ -130,6 +133,7 @@ export class CodexAcpServer implements acp.Agent {
130133
this.defaultAuthRequest = defaultAuthRequest ?? null;
131134
this.getExitCode = getExitCode ?? (() => null);
132135
this.clientInfo = null;
136+
this.terminalOutputMode = "terminal_output_delta";
133137
this.availableCommands = new CodexCommands(
134138
connection,
135139
codexAcpClient,
@@ -142,6 +146,7 @@ export class CodexAcpServer implements acp.Agent {
142146
): Promise<acp.InitializeResponse> {
143147
logger.log("Initialize request received");
144148
this.clientInfo = _params.clientInfo ?? null;
149+
this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities);
145150
await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params));
146151
return {
147152
protocolVersion: acp.PROTOCOL_VERSION,
@@ -353,6 +358,7 @@ export class CodexAcpServer implements acp.Agent {
353358
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
354359
currentModelSupportsFast: currentModelSupportsFast,
355360
sessionMcpServers: sessionMcpServers,
361+
terminalOutputMode: this.terminalOutputMode,
356362
}
357363
this.sessions.set(sessionId, sessionState);
358364
resumeSubscribed = false;
@@ -777,6 +783,7 @@ export class CodexAcpServer implements acp.Agent {
777783
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
778784
currentModelSupportsFast: currentModelSupportsFast,
779785
sessionMcpServers: sessionMcpServers,
786+
terminalOutputMode: this.terminalOutputMode,
780787
};
781788
this.sessions.set(sessionId, sessionState);
782789
subscribed = false;

src/CodexEventHandler.ts

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
ReasoningSummaryPartAddedNotification,
2424
ReasoningSummaryTextDeltaNotification,
2525
ReasoningTextDeltaNotification,
26+
TerminalInteractionNotification,
2627
ThreadGoalClearedNotification,
2728
ThreadGoalUpdatedNotification,
2829
ThreadTokenUsageUpdatedNotification,
@@ -32,6 +33,7 @@ import type {
3233
import type { McpStartupCompleteEvent } from "./app-server";
3334
import {toTokenCount} from "./TokenCount";
3435
import {
36+
commandExecutionUsesTerminalOutput,
3537
createCommandExecutionUpdate,
3638
createDynamicToolCallUpdate,
3739
createFileChangeUpdate,
@@ -51,6 +53,7 @@ import {
5153
fuzzyFileSearchToolCallId,
5254
} from "./CodexToolCallMapper";
5355
import { stripShellPrefix } from "./CommandUtils";
56+
import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode";
5457

5558
export { stripShellPrefix };
5659

@@ -64,6 +67,8 @@ export class CodexEventHandler {
6467
private readonly activeImageGenerationItems = new Set<string>();
6568
private readonly emittedImageViewItems = new Set<string>();
6669
private readonly seenReasoningDeltaItemIds = new Set<string>();
70+
private readonly terminalCommandIds = new Set<string>();
71+
private readonly terminalCommandOutputIds = new Set<string>();
6772

6873
constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
6974
this.connection = connection;
@@ -165,13 +170,14 @@ export class CodexEventHandler {
165170
return this.createThreadGoalUpdatedEvent(notification.params);
166171
case "thread/goal/cleared":
167172
return this.createThreadGoalClearedEvent(notification.params);
173+
case "item/commandExecution/terminalInteraction":
174+
return this.createTerminalInteractionEvent(notification.params);
168175
// ignored events
169176
case "command/exec/outputDelta":
170177
case "hook/started":
171178
case "hook/completed":
172179
case "turn/diff/updated":
173180
case "turn/moderationMetadata":
174-
case "item/commandExecution/terminalInteraction":
175181
case "item/fileChange/outputDelta":
176182
case "item/fileChange/patchUpdated":
177183
case "account/updated":
@@ -324,8 +330,15 @@ export class CodexEventHandler {
324330
switch (event.item.type) {
325331
case "fileChange":
326332
return await createFileChangeUpdate(event.item);
327-
case "commandExecution":
333+
case "commandExecution": {
334+
if (commandExecutionUsesTerminalOutput(event.item)) {
335+
this.terminalCommandIds.add(event.item.id);
336+
} else {
337+
this.terminalCommandIds.delete(event.item.id);
338+
this.terminalCommandOutputIds.delete(event.item.id);
339+
}
328340
return await createCommandExecutionUpdate(event.item);
341+
}
329342
case "mcpToolCall":
330343
return await createMcpToolCallUpdate(event.item);
331344
case "dynamicToolCall":
@@ -435,18 +448,40 @@ export class CodexEventHandler {
435448
}
436449

437450
private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent {
451+
if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) {
452+
this.terminalCommandOutputIds.add(event.itemId);
453+
}
454+
return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId));
455+
}
456+
457+
private createCommandOutputEvent(
458+
itemId: string,
459+
data: string,
460+
terminalOutputMode: TerminalOutputMode
461+
): UpdateSessionEvent {
438462
return {
439463
sessionUpdate: "tool_call_update",
440-
toolCallId: event.itemId,
441-
_meta: {
442-
terminal_output_delta: {
443-
data: event.delta,
444-
terminal_id: event.itemId
445-
}
446-
}
464+
toolCallId: itemId,
465+
_meta: createTerminalOutputMeta(terminalOutputMode, itemId, data),
447466
}
448467
}
449468

469+
private createTerminalInteractionEvent(event: TerminalInteractionNotification): UpdateSessionEvent {
470+
return this.createCommandOutputDeltaEvent({
471+
threadId: event.threadId,
472+
turnId: event.turnId,
473+
itemId: event.itemId,
474+
delta: `\n${event.stdin}\n`,
475+
});
476+
}
477+
478+
private commandOutputMode(itemId: string): TerminalOutputMode {
479+
if (this.sessionState.terminalOutputMode === "terminal_output" && !this.terminalCommandIds.has(itemId)) {
480+
return "terminal_output_delta";
481+
}
482+
return this.sessionState.terminalOutputMode;
483+
}
484+
450485
private createMcpToolProgressEvent(event: { itemId: string, message: string }): UpdateSessionEvent {
451486
const logDelta = event.message.trim();
452487
return {
@@ -495,22 +530,37 @@ export class CodexEventHandler {
495530
}
496531

497532
private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent {
498-
return {
533+
const update: UpdateSessionEvent = {
499534
sessionUpdate: "tool_call_update",
500535
toolCallId: item.id,
501536
status: item.status === "completed" ? "completed" : "failed",
502537
rawOutput: {
503538
formatted_output: item.aggregatedOutput ?? "",
504539
exit_code: item.exitCode
505540
},
506-
_meta: {
507-
terminal_exit: {
508-
exit_code: item.exitCode,
509-
signal: null,
510-
terminal_id: item.id
511-
}
512-
}
541+
};
542+
543+
const commandHadTerminal = this.terminalCommandIds.delete(item.id);
544+
const commandHadOutput = this.terminalCommandOutputIds.delete(item.id);
545+
if (!commandHadTerminal) {
546+
return update;
547+
}
548+
const terminalMeta: Record<string, unknown> = {};
549+
if (!commandHadOutput && item.aggregatedOutput) {
550+
Object.assign(
551+
terminalMeta,
552+
createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput)
553+
);
513554
}
555+
terminalMeta["terminal_exit"] = {
556+
exit_code: item.exitCode,
557+
signal: null,
558+
terminal_id: item.id
559+
};
560+
return {
561+
...update,
562+
_meta: terminalMeta,
563+
};
514564
}
515565

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

src/CodexToolCallMapper.ts

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ type GuardianApprovalReviewNotification =
3434
| ItemGuardianApprovalReviewStartedNotification
3535
| ItemGuardianApprovalReviewCompletedNotification;
3636
type WebSearchItem = ThreadItem & { type: "webSearch" };
37+
type CommandExecutionItem = ThreadItem & { type: "commandExecution" };
38+
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;
3739

3840
function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
3941
switch (status) {
@@ -66,32 +68,23 @@ export async function createFileChangeUpdate(
6668
};
6769
}
6870

69-
export async function createCommandExecutionUpdate(
70-
item: ThreadItem & { type: "commandExecution" }
71-
): Promise<UpdateSessionEvent> {
71+
export async function createCommandExecutionUpdate(item: CommandExecutionItem): Promise<UpdateSessionEvent> {
7272
const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined;
7373
if (commandAction) {
7474
return createCommandActionEvent(item.id, item.status, item.cwd, commandAction);
7575
}
7676
const command = stripShellPrefix(item.command);
77-
return {
77+
return createTerminalCommandEvent({
7878
sessionUpdate: "tool_call",
7979
toolCallId: item.id,
8080
kind: "execute",
8181
title: command,
8282
status: toAcpStatus(item.status),
83-
content: [{ type: "terminal", terminalId: item.id }],
8483
rawInput: {
8584
command: item.command,
8685
cwd: item.cwd,
8786
},
88-
_meta: {
89-
terminal_info: {
90-
cwd: item.cwd,
91-
terminal_id: item.id,
92-
},
93-
},
94-
};
87+
}, item.id, item.cwd);
9588
}
9689

9790
export async function createMcpToolCallUpdate(
@@ -348,50 +341,70 @@ function createCommandActionEvent(
348341
commandAction: CommandAction
349342
): UpdateSessionEvent {
350343
const acpStatus = toAcpStatus(status);
351-
if (commandAction.type === "read") {
352-
return {
353-
sessionUpdate: "tool_call",
354-
toolCallId: id,
355-
status: acpStatus,
356-
kind: "read",
357-
title: `Read file '${commandAction.path}'`,
358-
locations: [{ path: commandAction.path }],
359-
};
360-
} else if (commandAction.type === "search") {
361-
return {
362-
sessionUpdate: "tool_call",
363-
toolCallId: id,
364-
status: acpStatus,
365-
kind: "search",
366-
title: createSearchTitle(commandAction.query, commandAction.path),
367-
};
368-
} else if (commandAction.type === "listFiles") {
369-
const title = commandAction.path
370-
? `List files in '${commandAction.path}'`
371-
: "List files";
372-
return {
373-
sessionUpdate: "tool_call",
374-
toolCallId: id,
375-
status: acpStatus,
376-
kind: "read",
377-
title: title,
378-
};
344+
switch (commandAction.type) {
345+
case "read":
346+
return {
347+
sessionUpdate: "tool_call",
348+
toolCallId: id,
349+
status: acpStatus,
350+
kind: "read",
351+
title: `Read file '${commandAction.path}'`,
352+
locations: [{ path: commandAction.path }],
353+
};
354+
case "search":
355+
return {
356+
sessionUpdate: "tool_call",
357+
toolCallId: id,
358+
status: acpStatus,
359+
kind: "search",
360+
title: createSearchTitle(commandAction.query, commandAction.path),
361+
};
362+
case "listFiles": {
363+
const title = commandAction.path
364+
? `List files in '${commandAction.path}'`
365+
: "List files";
366+
return {
367+
sessionUpdate: "tool_call",
368+
toolCallId: id,
369+
status: acpStatus,
370+
kind: "read",
371+
title: title,
372+
};
373+
}
374+
case "unknown":
375+
return createTerminalCommandEvent({
376+
sessionUpdate: "tool_call",
377+
toolCallId: id,
378+
status: acpStatus,
379+
kind: "execute",
380+
title: stripShellPrefix(commandAction.command),
381+
rawInput: {
382+
command: commandAction.command,
383+
cwd,
384+
},
385+
}, id, cwd);
379386
}
387+
}
388+
389+
export function commandExecutionUsesTerminalOutput(item: CommandExecutionItem): boolean {
390+
const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined;
391+
return commandAction === undefined || commandAction.type === "unknown";
392+
}
393+
394+
function createTerminalCommandEvent(
395+
event: AcpToolCallEvent,
396+
terminalId: string,
397+
cwd: string,
398+
): UpdateSessionEvent {
399+
const { rawInput, ...eventWithoutRawInput } = event;
380400
return {
381-
sessionUpdate: "tool_call",
382-
toolCallId: id,
383-
status: acpStatus,
384-
kind: "execute",
385-
title: stripShellPrefix(commandAction.command),
386-
content: [{ type: "terminal", terminalId: id }],
387-
rawInput: {
388-
command: commandAction.command,
389-
cwd,
390-
},
401+
...eventWithoutRawInput,
402+
content: [{ type: "terminal", terminalId }],
403+
...(rawInput === undefined ? {} : { rawInput }),
391404
_meta: {
392405
terminal_info: {
393406
cwd,
394-
terminal_id: id,
407+
terminal_id: terminalId,
395408
},
396409
},
397410
};

0 commit comments

Comments
 (0)