fix(cli): harden ACP/Codex event handling (#211)

Co-authored-by: Lihengwannafly <Lihengwannafly@users.noreply.github.com>
This commit is contained in:
Lihengwannafly
2026-02-25 16:57:28 +08:00
committed by GitHub
co-authored by Lihengwannafly
parent 77cfa715f9
commit 10f7e1404e
17 changed files with 1529 additions and 67 deletions
+141 -13
View File
@@ -18,6 +18,7 @@ import { buildCodexStartConfig } from './utils/codexStartConfig';
import { AppServerEventConverter } from './utils/appServerEventConverter';
import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter';
import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig';
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
import {
RemoteLauncherBase,
type RemoteLauncherDisplayContext,
@@ -155,6 +156,15 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
return typeof value === 'string' && value.length > 0 ? value : null;
};
const buildMcpToolName = (server: unknown, tool: unknown): string | null => {
const serverName = asString(server);
const toolName = asString(tool);
if (!serverName || !toolName) {
return null;
}
return `mcp__${serverName}__${toolName}`;
};
const formatOutputPreview = (value: unknown): string => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
@@ -214,10 +224,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
this.permissionHandler = permissionHandler;
this.reasoningProcessor = reasoningProcessor;
this.diffProcessor = diffProcessor;
let readyAfterTurnTimer: ReturnType<typeof setTimeout> | null = null;
let scheduleReadyAfterTurn: (() => void) | null = null;
let clearReadyAfterTurnTimer: (() => void) | null = null;
let turnInFlight = false;
let allowAnonymousTerminalEvent = false;
const handleCodexEvent = (msg: Record<string, unknown>) => {
const msgType = asString(msg.type);
if (!msgType) return;
const eventTurnId = asString(msg.turn_id ?? msg.turnId);
const isTerminalEvent = msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed';
if (msgType === 'thread_started') {
const threadId = asString(msg.thread_id ?? msg.threadId);
@@ -229,14 +246,32 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
if (msgType === 'task_started') {
const turnId = asString(msg.turn_id ?? msg.turnId);
const turnId = eventTurnId;
if (turnId) {
this.currentTurnId = turnId;
allowAnonymousTerminalEvent = false;
} else if (useAppServer && !this.currentTurnId) {
allowAnonymousTerminalEvent = true;
}
}
if (msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') {
if (isTerminalEvent) {
if (shouldIgnoreTerminalEvent({
useAppServer,
eventTurnId,
currentTurnId: this.currentTurnId,
turnInFlight,
allowAnonymousTerminalEvent
})) {
logger.debug(
`[Codex] Ignoring terminal event ${msgType} without matching turn context; ` +
`eventTurnId=${eventTurnId ?? 'none'}, activeTurn=${this.currentTurnId ?? 'none'}, ` +
`turnInFlight=${turnInFlight}, allowAnonymous=${allowAnonymousTerminalEvent}`
);
return;
}
this.currentTurnId = null;
allowAnonymousTerminalEvent = false;
}
if (!useAppServer) {
@@ -274,28 +309,39 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
messageBuffer.addMessage('Starting task...', 'status');
} else if (msgType === 'task_complete') {
messageBuffer.addMessage('Task completed', 'status');
sendReady();
if (!useAppServer) {
sendReady();
}
} else if (msgType === 'turn_aborted') {
messageBuffer.addMessage('Turn aborted', 'status');
sendReady();
if (!useAppServer) {
sendReady();
}
} else if (msgType === 'task_failed') {
const error = asString(msg.error);
messageBuffer.addMessage(error ? `Task failed: ${error}` : 'Task failed', 'status');
sendReady();
if (!useAppServer) {
sendReady();
}
}
if (msgType === 'task_started') {
clearReadyAfterTurnTimer?.();
if (useAppServer) {
turnInFlight = true;
if (!eventTurnId && !this.currentTurnId) {
allowAnonymousTerminalEvent = true;
}
}
if (!session.thinking) {
logger.debug('thinking started');
session.onThinkingChange(true);
}
}
if (msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') {
if (isTerminalEvent) {
if (useAppServer) {
turnInFlight = false;
allowAnonymousTerminalEvent = false;
}
if (session.thinking) {
logger.debug('thinking completed');
@@ -304,6 +350,15 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
diffProcessor.reset();
appServerEventConverter?.reset();
}
if (useAppServer) {
if (isTerminalEvent && !turnInFlight) {
scheduleReadyAfterTurn?.();
} else if (readyAfterTurnTimer && msgType !== 'task_started') {
scheduleReadyAfterTurn?.();
}
}
if (msgType === 'agent_reasoning_section_break') {
reasoningProcessor.handleSectionBreak();
}
@@ -415,6 +470,48 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
});
}
}
if (msgType === 'mcp_tool_call_begin') {
const callId = asString(msg.call_id ?? msg.callId);
const invocation = asRecord(msg.invocation) ?? {};
const name = buildMcpToolName(
invocation.server ?? invocation.server_name ?? msg.server,
invocation.tool ?? invocation.tool_name ?? msg.tool
);
if (callId && name) {
session.sendCodexMessage({
type: 'tool-call',
name,
callId,
input: invocation.arguments ?? invocation.input ?? msg.arguments ?? msg.input ?? {},
id: randomUUID()
});
}
}
if (msgType === 'mcp_tool_call_end') {
const callId = asString(msg.call_id ?? msg.callId);
const rawResult = msg.result;
let output = rawResult;
let isError = false;
const resultRecord = asRecord(rawResult);
if (resultRecord) {
if (Object.prototype.hasOwnProperty.call(resultRecord, 'Ok')) {
output = resultRecord.Ok;
} else if (Object.prototype.hasOwnProperty.call(resultRecord, 'Err')) {
output = resultRecord.Err;
isError = true;
}
}
if (callId) {
session.sendCodexMessage({
type: 'tool-call-result',
callId,
output,
is_error: isError,
id: randomUUID()
});
}
}
if (msgType === 'turn_diff') {
const diff = asString(msg.unified_diff);
if (diff) {
@@ -492,7 +589,28 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
let currentModeHash: string | null = null;
let pending: { message: string; mode: EnhancedMode; isolate: boolean; hash: string } | null = null;
let first = true;
let turnInFlight = false;
clearReadyAfterTurnTimer = () => {
if (!readyAfterTurnTimer) {
return;
}
clearTimeout(readyAfterTurnTimer);
readyAfterTurnTimer = null;
};
scheduleReadyAfterTurn = () => {
clearReadyAfterTurnTimer?.();
readyAfterTurnTimer = setTimeout(() => {
readyAfterTurnTimer = null;
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),
shouldExit: this.shouldExit,
sendReady
});
}, 120);
readyAfterTurnTimer.unref?.();
};
while (!this.shouldExit) {
logActiveHandles('loop-top');
@@ -589,6 +707,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
allowAnonymousTerminalEvent = false;
const turnResponse = await appServerClient.startTurn(turnParams, {
signal: this.abortController.signal
});
@@ -597,6 +716,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const turnId = asString(turn?.id);
if (turnId) {
this.currentTurnId = turnId;
} else if (!this.currentTurnId) {
allowAnonymousTerminalEvent = true;
}
} else if (mcpClient) {
const startConfig: CodexSessionConfig = buildCodexStartConfig({
@@ -628,6 +749,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
allowAnonymousTerminalEvent = false;
const turnResponse = await appServerClient.startTurn(turnParams, {
signal: this.abortController.signal
});
@@ -636,6 +758,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const turnId = asString(turn?.id);
if (turnId) {
this.currentTurnId = turnId;
} else if (!this.currentTurnId) {
allowAnonymousTerminalEvent = true;
}
} else if (mcpClient) {
await mcpClient.continueSession(message.message, { signal: this.abortController.signal });
@@ -646,6 +770,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const isAbortError = error instanceof Error && error.name === 'AbortError';
if (useAppServer) {
turnInFlight = false;
allowAnonymousTerminalEvent = false;
this.currentTurnId = null;
}
if (isAbortError) {
@@ -666,12 +792,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
}
} finally {
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
appServerEventConverter?.reset();
session.onThinkingChange(false);
if (!useAppServer || !turnInFlight) {
const shouldFinalizeTurnState = !useAppServer || !turnInFlight;
if (shouldFinalizeTurnState) {
permissionHandler.reset();
reasoningProcessor.abort();
diffProcessor.reset();
appServerEventConverter?.reset();
session.onThinkingChange(false);
clearReadyAfterTurnTimer?.();
emitReadyIfIdle({
pending,
queueSize: () => session.queue.size(),