Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export class CodexAcpClient {
if (!gatewaySettings) throw RequestError.invalidRequest();

const baseUrl = gatewaySettings.baseUrl;
const providerName = typeof gatewaySettings.providerName === "string" && gatewaySettings.providerName.trim().length > 0
? gatewaySettings.providerName
: "User-provided gateway";
const headers: Record<string, string> = {
"X-Client-Feature-ID": "codex",
...gatewaySettings.headers
Expand All @@ -110,7 +113,7 @@ export class CodexAcpClient {
this.gatewayConfig = {
modelProvider: "custom-gateway",
config: {
name: "User-provided gateway",
name: providerName,
base_url: baseUrl,
http_headers: headers,
wire_api: "responses"
Expand Down Expand Up @@ -359,7 +362,7 @@ export class CodexAcpClient {

async subscribeToSessionEvents(
sessionId: string,
eventHandler: (result: ServerNotification) => void,
eventHandler: (result: ServerNotification) => void | Promise<void>,
approvalHandler: ApprovalHandler
) {
this.codexClient.onServerNotification(sessionId, eventHandler);
Expand Down Expand Up @@ -393,7 +396,9 @@ export class CodexAcpClient {

// Wait for turn completion
// If turnInterrupt() was called, Codex will send turn/completed event with status "interrupted"
return await this.codexClient.awaitTurnCompleted();
const turnCompleted = await this.codexClient.awaitTurnCompleted();
await this.codexClient.flushServerNotifications(request.sessionId);
return turnCompleted;
}

async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
Expand Down
6 changes: 4 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
createFileChangeUpdate,
createMcpToolCallUpdate,
} from "./CodexToolCallMapper";
import { createApprovalContextStore } from "./CodexApprovalContext";

export interface SessionState {
sessionId: string,
Expand Down Expand Up @@ -705,8 +706,9 @@ export class CodexAcpServer implements acp.Agent {
sessionState.lastTokenUsage = null;

try {
const eventHandler = new CodexEventHandler(this.connection, sessionState);
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState);
const approvalContext = createApprovalContextStore();
const eventHandler = new CodexEventHandler(this.connection, sessionState, approvalContext);
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, approvalContext);
await this.codexAcpClient.subscribeToSessionEvents(params.sessionId,
(event) => eventHandler.handleNotification(event),
approvalHandler);
Expand Down
62 changes: 58 additions & 4 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@ import type {
SkillsListResponse,
ListMcpServerStatusParams,
ListMcpServerStatusResponse, ConfigReadParams, ConfigReadResponse,
McpServerElicitationRequestParams,
McpServerElicitationRequestResponse,
} from "./app-server/v2";
import { logger } from "./Logger";

export interface ApprovalHandler {
handleCommandExecution(params: CommandExecutionRequestApprovalParams): Promise<CommandExecutionRequestApprovalResponse>;
handleFileChange(params: FileChangeRequestApprovalParams): Promise<FileChangeRequestApprovalResponse>;
handleMcpServerElicitation(params: McpServerElicitationRequestParams): Promise<McpServerElicitationRequestResponse>;
}

const CommandExecutionApprovalRequest = new RequestType<
Expand All @@ -53,13 +57,21 @@ const FileChangeApprovalRequest = new RequestType<
void
>('item/fileChange/requestApproval');

const McpServerElicitationRequest = new RequestType<
McpServerElicitationRequestParams,
McpServerElicitationRequestResponse,
void
>('mcpServer/elicitation/request');

/**
* A type-safe client over the Codex App Server's JSON-RPC API.
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
*/
export class CodexAppServerClient {
readonly connection: MessageConnection;
private approvalHandlers = new Map<string, ApprovalHandler>();
private readonly notificationHandlers = new Map<string, (event: ServerNotification) => void | Promise<void>>();
private readonly notificationQueues = new Map<string, Promise<void> | null>();
private mcpStartupCompleteVersion = 0;
private lastMcpStartupComplete: McpStartupCompleteEvent | null = null;
private readonly mcpStartupCompleteResolvers: Array<SignalResolver<McpStartupCompleteEvent>> = [];
Expand Down Expand Up @@ -98,6 +110,14 @@ export class CodexAppServerClient {
}
return await handler.handleFileChange(params);
});

this.connection.onRequest(McpServerElicitationRequest, async (params) => {
const handler = this.approvalHandlers.get(params.threadId);
if (!handler) {
return { action: "cancel", content: null, _meta: null };
}
return await handler.handleMcpServerElicitation(params);
});
}

onApprovalRequest(threadId: string, handler: ApprovalHandler): void {
Expand Down Expand Up @@ -190,22 +210,56 @@ export class CodexAppServerClient {
* Registers a notification handler for a specific session.
* Replaces any existing handler for the same session, preventing handler accumulation.
*/
onServerNotification(sessionId: string, callback: (event: ServerNotification) => void) {
onServerNotification(sessionId: string, callback: (event: ServerNotification) => void | Promise<void>) {
this.notificationHandlers.set(sessionId, callback);
this.notificationQueues.set(sessionId, null);
}

private codexEventHandlers: Array<(event: CodexConnectionEvent) => void> = [];
onClientTransportEvent(callback: (event: CodexConnectionEvent) => void){
this.codexEventHandlers.push(callback);
}

private notificationHandlers = new Map<string, (event: ServerNotification) => void>();
private notify(notification: ServerNotification) {
for (const notificationHandler of this.notificationHandlers.values()) {
notificationHandler(notification);
for (const [sessionId, notificationHandler] of this.notificationHandlers.entries()) {
const queue = this.notificationQueues.get(sessionId);
if (queue) {
const next = queue
.then(() => notificationHandler(notification))
.catch((error) => {
logger.error("Error handling server notification", error);
});
this.notificationQueues.set(sessionId, this.trackNotificationQueue(sessionId, next));
continue;
}

try {
const result = notificationHandler(notification);
if (result instanceof Promise) {
const next = result.catch((error) => {
logger.error("Error handling server notification", error);
});
this.notificationQueues.set(sessionId, this.trackNotificationQueue(sessionId, next));
}
} catch (error) {
logger.error("Error handling server notification", error);
}
}
}

async flushServerNotifications(sessionId: string): Promise<void> {
await (this.notificationQueues.get(sessionId) ?? Promise.resolve());
}

private trackNotificationQueue(sessionId: string, queue: Promise<void>): Promise<void> {
const trackedQueue = queue.finally(() => {
if (this.notificationQueues.get(sessionId) === trackedQueue) {
this.notificationQueues.set(sessionId, null);
}
});
return trackedQueue;
}

private resolveSignal<T>(
event: T,
version: number,
Expand Down
13 changes: 13 additions & 0 deletions src/CodexApprovalContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ThreadItem } from "./app-server/v2";

export interface ApprovalContextStore {
readonly fileChangesByItemId: Map<string, ThreadItem & { type: "fileChange" }>;
readonly turnDiffsByTurnId: Map<string, string>;
}

export function createApprovalContextStore(): ApprovalContextStore {
return {
fileChangesByItemId: new Map(),
turnDiffsByTurnId: new Map(),
};
}
Loading
Loading