fix(codex): preserve transcript final answer ordering

This commit is contained in:
weishu
2026-08-10 10:36:34 +08:00
parent 190293ce9e
commit c2cc73159c
6 changed files with 886 additions and 99 deletions
+106 -73
View File
@@ -5,7 +5,12 @@ import { codexLocal } from './codexLocal';
import type { ReasoningEffort } from './appServerTypes';
import { CodexSession } from './session';
import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner';
import { createCodexEventConverter, type CodexMessage, type CodexSessionEvent } from './utils/codexEventConverter';
import {
createCodexEventConverter,
type CodexConversionAction,
type CodexMessage,
type CodexSessionEvent
} from './utils/codexEventConverter';
import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge';
import { parseCodexCliOverrides, stripCodexCliOverrides } from './utils/codexCliOverrides';
import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig';
@@ -89,21 +94,6 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
});
};
const drainAndCleanupScanner = async (
activeScanner: CodexSessionScanner,
replayedExistingHistory: boolean
): Promise<void> => {
try {
// Codex can flush its final transcript records after the last watcher tick.
await activeScanner.flush();
if (replayedExistingHistory) {
session.markTranscriptHistoryReplayConsumed();
}
} finally {
await activeScanner.cleanup();
}
};
const handleSessionFound = (sessionId: string, allowSwitch = false): void => {
if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) {
logger.debug(`[codex-local]: Ignoring non-primary Codex session id ${sessionId}; primary is ${primarySessionId}`);
@@ -173,6 +163,100 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
pendingExecWrappers.clear();
};
const dispatchTranscriptActions = (
actions: CodexConversionAction[],
context: { replayedHistory: boolean }
): void => {
for (const action of actions) {
if (action.type === 'session-found') {
if (!isPrimarySessionId(action.sessionId)) {
logger.debug(`[codex-local]: Ignoring converted session id ${action.sessionId}; primary is ${primarySessionId}`);
return;
}
session.onSessionFound(action.sessionId);
continue;
}
if (action.type === 'user-message') {
session.sendUserMessage(action.message);
continue;
}
if (action.type === 'user-activity') {
session.notifyUserActivity();
continue;
}
if (action.type === 'turn-finished') {
flushPendingPlan(action.turnId);
for (const message of toolHookBridge.finishTurn(action.turnId)) {
session.sendAgentMessage(message);
}
continue;
}
const message = action.message;
if (message.type === 'proposed_plan') {
// Codex may complete the Plan item before emitting its final text preface.
pendingPlansByTurnId.set(message.turnId, message);
} else if (message.type === 'tool-call' && message.name === 'exec') {
if (countHookCoveredExecCalls(message.input) === null) {
session.sendAgentMessage(message);
} else {
pendingExecWrappers.set(message.callId, {
message,
...(action.turnId ? { turnId: action.turnId } : {})
});
}
} else if (message.type === 'tool-call-result' && pendingExecWrappers.has(message.callId)) {
const pending = pendingExecWrappers.get(message.callId);
const turnId = pending?.turnId ?? action.turnId;
if (pending && toolHookBridge.hasCompletedAllObservedNestedTools(turnId)) {
pendingExecWrappers.delete(message.callId);
} else {
flushPendingExecWrapper(message.callId, message);
}
} else {
const scopedMessage = message.type !== 'token_count'
? message
: context.replayedHistory
? { ...message, model: transcriptModel, hapiUsageScope: 'imported-history' }
: primarySessionId
? {
...message,
model: transcriptModel,
threadId: primarySessionId,
thread_id: primarySessionId,
hapiUsageScope: 'managed'
}
: { ...message, model: transcriptModel };
session.sendAgentMessage(scopedMessage);
}
}
};
const finalizeTranscriptConversion = (replayedExistingHistory: boolean): void => {
dispatchTranscriptActions(convertTranscriptEvent.finalize(), {
replayedHistory: replayedExistingHistory
});
};
const drainAndCleanupScanner = async (
activeScanner: CodexSessionScanner,
replayedExistingHistory: boolean
): Promise<void> => {
try {
// Codex can flush its final transcript records after the last watcher tick.
await activeScanner.flush();
if (replayedExistingHistory) {
session.markTranscriptHistoryReplayConsumed();
}
} finally {
try {
await activeScanner.cleanup();
} finally {
finalizeTranscriptConversion(replayedExistingHistory);
}
}
};
const bindPrimarySession = (sessionId: string, transcriptPath: string, allowSwitch = false): void => {
if (primarySessionId && primarySessionId !== sessionId && !allowSwitch) {
logger.debug(`[codex-local]: Ignoring non-primary SessionStart hook ${sessionId}; primary is ${primarySessionId}`);
@@ -196,6 +280,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
}
if (scanner) {
if (scannerTranscriptPath !== transcriptPath) {
await scanner.flush();
finalizeTranscriptConversion(scannerReplayedExistingHistory);
flushAllPendingPlans();
convertTranscriptEvent = createCodexEventConverter();
}
@@ -208,6 +294,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
transcriptPath,
// 中文注释:导入模式下允许 scanner 首次回放 transcript 全量内容,补齐 Codex 客户端里已有但 Hapi 还未看到的消息。
replayExistingHistory,
onReplayComplete: () => {
finalizeTranscriptConversion(true);
},
onSessionId: (sessionId) => {
if (!isPrimarySessionId(sessionId)) {
logger.debug(`[codex-local]: Ignoring transcript session id ${sessionId}; primary is ${primarySessionId}`);
@@ -224,63 +313,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
if (observedReasoningEffort !== undefined) {
session.setModelReasoningEffort(observedReasoningEffort);
}
const converted = convertTranscriptEvent(event);
if (converted?.sessionId) {
if (!isPrimarySessionId(converted.sessionId)) {
logger.debug(`[codex-local]: Ignoring converted session id ${converted.sessionId}; primary is ${primarySessionId}`);
return;
}
session.onSessionFound(converted.sessionId);
}
if (converted?.userMessage) {
session.sendUserMessage(converted.userMessage);
} else if (converted?.userActivity) {
session.notifyUserActivity();
}
for (const message of converted?.messages ?? []) {
if (message.type === 'proposed_plan') {
// Codex may complete the Plan item before emitting its final text preface.
pendingPlansByTurnId.set(message.turnId, message);
} else if (message.type === 'tool-call' && message.name === 'exec') {
if (countHookCoveredExecCalls(message.input) === null) {
session.sendAgentMessage(message);
} else {
pendingExecWrappers.set(message.callId, {
message,
...(converted?.turnId ? { turnId: converted.turnId } : {})
});
}
} else if (message.type === 'tool-call-result' && pendingExecWrappers.has(message.callId)) {
const pending = pendingExecWrappers.get(message.callId);
const turnId = pending?.turnId ?? converted?.turnId;
if (pending && toolHookBridge.hasCompletedAllObservedNestedTools(turnId)) {
pendingExecWrappers.delete(message.callId);
} else {
flushPendingExecWrapper(message.callId, message);
}
} else {
const scopedMessage = message.type !== 'token_count'
? message
: context.replayedHistory
? { ...message, model: transcriptModel, hapiUsageScope: 'imported-history' }
: primarySessionId
? {
...message,
model: transcriptModel,
threadId: primarySessionId,
thread_id: primarySessionId,
hapiUsageScope: 'managed'
}
: { ...message, model: transcriptModel };
session.sendAgentMessage(scopedMessage);
}
}
if (converted?.finishedTurnId) {
flushPendingPlan(converted.finishedTurnId);
for (const message of toolHookBridge.finishTurn(converted.finishedTurnId)) {
session.sendAgentMessage(message);
}
}
dispatchTranscriptActions(convertTranscriptEvent(event), context);
}
});
if (shuttingDown) {