763 lines
43 KiB
JavaScript
763 lines
43 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
// 抽取服务端真实重试、开轮、完成与重连函数,只把模型边界和时钟替换为隔离夹具。
|
||
// 会话及完整运行态均使用真实存储实现,所有读写限制在临时目录。
|
||
const assert = require('node:assert/strict');
|
||
const crypto = require('node:crypto');
|
||
const fs = require('node:fs');
|
||
const os = require('node:os');
|
||
const path = require('node:path');
|
||
const { inspect } = require('node:util');
|
||
const vm = require('node:vm');
|
||
const { createServerHarness } = require('./session-history-store-unit');
|
||
const { createCodexAppServerClient } = require('../lib/codex-app-server-client');
|
||
const { LIFECYCLE_EVENT_TYPES: TASK_BOARD_LIFECYCLE_EVENTS } = require('../lib/task-board-lifecycle');
|
||
const { CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS } = require('../lib/task-board-classifier');
|
||
|
||
const REPO_DIR = path.join(__dirname, '..');
|
||
const baselineIndex = process.argv.indexOf('--baseline');
|
||
const sourcePath = baselineIndex >= 0 ? process.argv[baselineIndex + 1] : path.join(REPO_DIR, 'server.js');
|
||
assert(sourcePath, '--baseline 需要提供修复前工作区 server.js 快照路径');
|
||
const serverSource = fs.readFileSync(sourcePath, 'utf8');
|
||
const runtimePath = baselineIndex >= 0 && fs.existsSync(path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js'))
|
||
? path.join(path.dirname(sourcePath), 'ccweb-streaming-retry-baseline-runtime.js')
|
||
: path.join(REPO_DIR, 'lib/codex-app-runtime.js');
|
||
const { createCodexAppRuntime } = require(runtimePath);
|
||
|
||
function extractFunction(name, optional = false) {
|
||
const match = new RegExp(`(?:^|\\n)(?:async )?function ${name}\\(`).exec(serverSource);
|
||
if (!match && optional) return '';
|
||
assert(match, `缺少生产函数 ${name}`);
|
||
const start = match.index + (serverSource[match.index] === '\n' ? 1 : 0);
|
||
const closingLine = /\n\}/g;
|
||
closingLine.lastIndex = start;
|
||
let end;
|
||
while ((end = closingLine.exec(serverSource))) {
|
||
const candidate = serverSource.slice(start, end.index + 2);
|
||
try {
|
||
new vm.Script(`(${candidate})`, { filename: sourcePath });
|
||
return candidate;
|
||
} catch (error) {
|
||
if (!(error instanceof SyntaxError)) throw error;
|
||
}
|
||
}
|
||
throw new Error(`${name} 函数体不完整`);
|
||
}
|
||
|
||
function extractConst(name) {
|
||
const match = serverSource.match(new RegExp(`^const ${name} = .*;$`, 'm'));
|
||
assert(match, `缺少生产常量 ${name}`);
|
||
return match[0];
|
||
}
|
||
|
||
function createApi(dir, options = {}) {
|
||
const storeApi = createServerHarness(dir);
|
||
const sent = [];
|
||
const logs = [];
|
||
const starts = [];
|
||
const runtimeRef = { current: null };
|
||
const timers = new Map();
|
||
const maps = Object.fromEntries([
|
||
'activeProcesses', 'activeCodexAppTurns', 'activeCodexAppGoalCommands', 'activeCodexAppCompactions',
|
||
'codexAppGoalStates', 'pendingCompactRetries', 'pendingCodexCapacityRetries', 'retainedCodexAppTurns',
|
||
'retiredCodexAppRuntimeTurns', 'wsSessionMap', 'ccwebMcpChildThreads',
|
||
].map(name => [name, new Map()]));
|
||
const realSaveTurn = storeApi.sessionHistoryStore.saveTurn.bind(storeApi.sessionHistoryStore);
|
||
storeApi.sessionHistoryStore.saveTurn = (...args) => {
|
||
if (options.failTurnSave) { const error = new Error('夹具:完整运行态保存失败'); error.code = 'ENOSPC'; throw error; }
|
||
return realSaveTurn(...args);
|
||
};
|
||
const requiredNames = [
|
||
'readPositiveIntEnv', 'sanitizeId', 'runDir', 'writeFileAtomicSync', 'cleanRunDir',
|
||
'textByteLength', 'truncateTextValue', 'sanitizePersistValue', 'sanitizeToolCallsForPersist',
|
||
'mapToPairs', 'codexAppStatePath', 'codexAppTurnKey', 'serializeCodexAppEntry',
|
||
'buildCodexAppStateJson', 'writeCodexAppTurnState', 'persistCodexAppTurnState',
|
||
'cleanupCodexAppTurnState', 'loadCodexAppTurnState', 'hasCodexAppTurnMessage',
|
||
'appendCodexAppRecoveryNotice', 'recoverCodexAppTurnState',
|
||
'hasRuntimeOutput', 'retryTailText', 'retryPreviewValue', 'buildCodexRetryToolSummary',
|
||
'shouldUseCodexAppContinuationRetry', 'buildCodexAppContinuationRetryText',
|
||
'isCodexTransientCapacityError', 'isCodexTransientConnectionError', 'isCodexTransientRetryableError',
|
||
'codexTransientRetryDelayMs', 'cancelCodexCapacityRetry', 'shouldRetryCodexTransientFailure',
|
||
'scheduleCodexCapacityRetry', 'stableMessageHash', 'messageReplyToRequestId', 'ensureStableMessageId',
|
||
'handleMessage', 'handleCodexAppMessage', 'handleCodexAppTurnComplete', 'handleCodexAppTurnFailure',
|
||
'attachActiveRuntimeToWs', 'handleResumeSession', 'isSessionRunning',
|
||
'bindAbortSessionToWs', 'handleCodexAppAbortSession', 'handleAbort',
|
||
'codexAppRuntimeThreadId', 'codexAppRuntimeTurnId', 'findCodexAppEntryByRuntime',
|
||
'isCodexAppAdoptableRuntimeMethod', 'adoptCodexAppUnroutedTurn', 'findCodexAppRouteByRuntime',
|
||
'handleCodexAppNotification', 'handleCodexAppServerExit',
|
||
];
|
||
const constNames = [
|
||
'CODEX_APP_STATE_FULL_TEXT_MAX_CHARS', 'CODEX_APP_STATE_MAP_VALUE_MAX_CHARS', 'CODEX_APP_STATE_MAX_MAP_ENTRIES',
|
||
'CODEX_APP_STATE_MAX_BYTES', 'CODEX_APP_STATE_LOAD_MAX_BYTES', 'CODEX_APP_STATE_FILE',
|
||
'CODEX_APP_STATE_FLUSH_DELAY_MS', 'SESSION_MESSAGE_CONTENT_MAX_CHARS',
|
||
'SESSION_TOOL_INPUT_MAX_CHARS', 'SESSION_TOOL_RESULT_MAX_CHARS', 'SESSION_MAX_TOOL_CALLS_PER_MESSAGE',
|
||
'PERSIST_TRUNCATED_TEXT',
|
||
];
|
||
const optionalNames = [
|
||
'ensureCodexAppAssistantMessageId', 'buildCodexAppResumePayload', 'retirePendingCodexCapacityRetry',
|
||
'retireCodexAppRuntimeTurn', 'codexAppAssistantMessageIdForEntry',
|
||
];
|
||
const api = new Function('deps', `
|
||
const { fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef,
|
||
TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS } = deps;
|
||
const SESSIONS_DIR = dir;
|
||
const INITIAL_HISTORY_COUNT = 12;
|
||
const DEFAULT_CODEX_CONFIG = { retry: { intervalSeconds: 1 } };
|
||
const { activeProcesses, activeCodexAppTurns, activeCodexAppGoalCommands, activeCodexAppCompactions,
|
||
codexAppGoalStates, pendingCompactRetries, pendingCodexCapacityRetries, retainedCodexAppTurns,
|
||
retiredCodexAppRuntimeTurns, wsSessionMap, ccwebMcpChildThreads } = maps;
|
||
const sessionHistoryStore = storeApi.sessionHistoryStore;
|
||
const loadSession = id => options.failSessionLoad ? null : storeApi.loadSession(id);
|
||
const saveSession = session => options.failSessionSave ? false : storeApi.saveSession(session);
|
||
const wsSend = (ws, payload) => sent.push({ ...payload, _ws: ws });
|
||
const plog = (level, event, data) => logs.push({ level, event, ...data });
|
||
const setTimeout = (callback, delay) => {
|
||
const timer = { callback, delay, unref() {} }; timers.set(timer, timer); return timer;
|
||
};
|
||
const clearTimeout = timer => timers.delete(timer);
|
||
const getCodexRetryConfig = () => ({ mode: 'limited', maxAttempts: 3, intervalSeconds: 1, ...options.retryConfig });
|
||
const crossConversationReplyRequestIds = () => [];
|
||
const resolveMessageAttachments = () => [];
|
||
const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] });
|
||
const attachClientRequestId = (payload, source) => ({ ...payload, requestId: source?.requestId });
|
||
const isCodexAppGoalActive = () => false;
|
||
const isCodexAppSession = session => session?.agent === 'codexapp';
|
||
const getSessionAgent = session => session?.agent || 'claude';
|
||
const getRuntimeSessionId = session => session?.codexAppThreadId || null;
|
||
const getDefaultSessionCwd = () => dir;
|
||
const normalizeAgent = agent => agent || 'codexapp';
|
||
const getDefaultCodexModel = () => 'fixture-model';
|
||
const normalizeSession = session => session;
|
||
const normalizeGiteaSource = value => value || null;
|
||
const reconcilePendingCrossConversationReplies = () => false;
|
||
const flushPendingCrossConversationReplies = () => false;
|
||
const detachWsFromActiveRuntimes = () => {};
|
||
const sendSessionList = () => {};
|
||
const broadcastSessionList = () => {};
|
||
const dispatchTaskBoardLifecycle = () => Promise.resolve(null);
|
||
const enqueueTaskStatusClassification = () => Promise.resolve(null);
|
||
const resolvePendingCodexAppUserInputsForSession = () => {};
|
||
const resolvePendingCodexAppApprovalsForSession = () => {};
|
||
const resolvePendingCodexAppElicitationsForSession = () => {};
|
||
const formatRuntimeError = (agent, raw) => String(raw || '');
|
||
const completeCrossConversationReply = () => false;
|
||
const recoverCcwebMcpChildThreadsFromPersistedToolCalls = () => {};
|
||
const setRuntimeSessionId = (session, threadId) => { session.codexAppThreadId = threadId; };
|
||
const resolveSessionHistory = storeApi.resolveSessionHistory;
|
||
const sanitizeMessageForTransport = value => value;
|
||
const sanitizeMessagesForTransport = value => value;
|
||
const wss = { clients: [] };
|
||
const buildNotifyContent = async () => ({ title: '', content: '' });
|
||
const sendNotification = () => {};
|
||
const giteaWorkflowWaiters = new Map();
|
||
const settleGiteaWorkflowTurn = () => Promise.resolve(null);
|
||
let codexAppClient = options.runtimeClient || null;
|
||
let codexAppClientSignature = 'fixture-signature';
|
||
const pauseCodexAppGoalForAbort = () => false;
|
||
const cancelCodexAppGoalCommand = () => false;
|
||
const cancelCodexAppCompaction = () => false;
|
||
const codexAppInputFromMessage = text => text ? [{ type: 'text', text }] : [];
|
||
const normalizeCodexAppThreadId = value => String(value || '').trim();
|
||
const getCodexAppClient = () => ({ client: options.runtimeClient });
|
||
const codexAppThreadParams = () => ({});
|
||
const ensureCodexAppMcpReadyForThread = async () => {};
|
||
const codexAppTurnParams = (session, input, threadId, clientUserMessageId) => ({ input, threadId, clientUserMessageId });
|
||
const codexAppRuntime = { processCodexAppNotification: (...args) => runtimeRef.current.processCodexAppNotification(...args) };
|
||
const handleCodexAppGoalNotification = () => false;
|
||
const handleCodexAppMcpStartupStatusNotification = () => false;
|
||
const shouldLogCodexAppUnroutedNotification = () => false;
|
||
const syncCcwebMcpChildAgentsFromCollabItem = () => {};
|
||
const processCcwebMcpChildNotification = () => ({ changed: false });
|
||
const sendCcwebMcpChildAgentUpdate = () => {};
|
||
const findViewingSessionWs = id => [...wsSessionMap].find(([, sessionId]) => sessionId === id)?.[0] || null;
|
||
const findCodexAppSessionByThreadId = threadId => {
|
||
for (const id of new Set(wsSessionMap.values())) {
|
||
const session = loadSession(id);
|
||
if (session?.codexAppThreadId === threadId) return { sessionId: id, session };
|
||
}
|
||
return null;
|
||
};
|
||
// 仅替换真正的模型边界;调度器、hideInHistory 与新 entry 的交接全部执行生产函数。
|
||
let startCodexAppTurn = async (sessionId, input) => {
|
||
const entry = activeCodexAppTurns.get(sessionId);
|
||
starts.push({ sessionId, input });
|
||
if (options.failStart) throw new Error('夹具:线程启动失败');
|
||
if (options.startTurn) { await options.startTurn(sessionId, input, entry); return; }
|
||
entry.turnId = 'fixture-turn-' + starts.length;
|
||
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||
};
|
||
${requiredNames.map(name => extractFunction(name)).join('\n')}
|
||
${optionalNames.map(name => extractFunction(name, true)).join('\n')}
|
||
${constNames.map(name => extractConst(name)).join('\n')}
|
||
if (options.realStart) startCodexAppTurn = (${extractFunction('startCodexAppTurn')});
|
||
return {
|
||
handleMessage, handleCodexAppMessage, handleCodexAppTurnComplete, handleCodexAppTurnFailure,
|
||
scheduleCodexCapacityRetry, cancelCodexCapacityRetry, attachActiveRuntimeToWs, handleResumeSession,
|
||
handleAbort, writeCodexAppTurnState, loadCodexAppTurnState, persistCodexAppTurnState,
|
||
recoverCodexAppTurnState, codexAppTurnKey, isSessionRunning, findCodexAppEntryByRuntime,
|
||
handleCodexAppNotification, handleCodexAppServerExit,
|
||
};
|
||
`)({ fs, path, crypto, dir, storeApi, sent, logs, starts, timers, maps, options, runtimeRef,
|
||
TASK_BOARD_LIFECYCLE_EVENTS, TASK_STATUS_CLASSIFICATION_EVENTS });
|
||
const runtime = createCodexAppRuntime({ wsSend: (ws, payload) => sent.push({ ...payload, _ws: ws }),
|
||
loadSession: storeApi.loadSession, saveSession: storeApi.saveSession });
|
||
runtimeRef.current = runtime;
|
||
async function fireRetry(sessionId) {
|
||
const retry = maps.pendingCodexCapacityRetries.get(sessionId);
|
||
assert(retry?.timer, '应已安排自动重试计时器');
|
||
timers.delete(retry.timer);
|
||
retry.timer.callback();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
}
|
||
return { ...api, ...maps, runtime, storeApi, sent, logs, starts, timers, fireRetry, options };
|
||
}
|
||
|
||
const ERROR = 'Our servers are currently overloaded. Please try again later.';
|
||
const A = 'A正文首段唯一\n' + '甲'.repeat(220000) + '\nA正文尾段唯一';
|
||
const B = 'B正文唯一\n' + '乙'.repeat(15000) + '\nB正文结束唯一';
|
||
const C = 'C正文唯一\n' + '丙'.repeat(15000) + '\nC正文结束唯一';
|
||
|
||
function seed(api, id = 'retry-history', text = '继续长任务') {
|
||
const session = { id, title: '重试历史夹具', agent: 'codexapp', codexAppThreadId: 'fixture-thread',
|
||
model: 'fixture-model', permissionMode: 'yolo', cwd: '/fixture', messages: [] };
|
||
assert.equal(api.storeApi.saveSession(session), true);
|
||
const ws = { readyState: 1, label: 'initial' };
|
||
assert.equal(api.handleMessage(ws, { type: 'message', sessionId: id, agent: 'codexapp',
|
||
clientMessageId: 'original-user', text, mode: 'yolo' }).ok, true);
|
||
return { id, ws };
|
||
}
|
||
|
||
function emit(api, id, text, label, reusedId = false) {
|
||
const entry = api.activeCodexAppTurns.get(id);
|
||
assert(entry, '应有活动 entry');
|
||
const base = { threadId: entry.threadId, turnId: entry.turnId };
|
||
const agentId = reusedId ? 'agent-fixed' : 'agent-' + label;
|
||
const toolId = reusedId ? 'tool-fixed' : 'tool-' + label;
|
||
for (let offset = 0; offset < text.length; offset += 8192) {
|
||
api.runtime.processCodexAppNotification(entry, { method: 'item/agentMessage/delta',
|
||
params: { ...base, itemId: agentId, delta: text.slice(offset, offset + 8192) } }, id);
|
||
}
|
||
api.runtime.processCodexAppNotification(entry, { method: 'item/completed',
|
||
params: { ...base, item: { id: agentId, type: 'agentMessage', text } } }, id);
|
||
api.runtime.processCodexAppNotification(entry, { method: 'item/started',
|
||
params: { ...base, item: { id: toolId, type: 'commandExecution',
|
||
command: 'fixture-' + label + '-' + '输入'.repeat(10000), status: 'inProgress' } } }, id);
|
||
api.runtime.processCodexAppNotification(entry, { method: 'item/commandExecution/outputDelta',
|
||
params: { ...base, itemId: toolId, delta: '工具' + label + '结果' + '输出'.repeat(20000) } }, id);
|
||
api.writeCodexAppTurnState(id, entry);
|
||
return entry;
|
||
}
|
||
|
||
function assertToolOrder(tools, labels) {
|
||
assert.equal(tools.length, labels.length, '工具调用数量不能缩水或重复');
|
||
labels.forEach((label, index) => assert(tools[index].result.includes('工具' + label + '结果'), '工具结果必须按开轮顺序保留'));
|
||
assert.equal(new Set(tools.map(tool => tool.id)).size, labels.length, '跨轮同原生 ID 必须独立保存');
|
||
}
|
||
|
||
function assertOnceInOrder(text, parts) {
|
||
let end = -1;
|
||
for (const part of parts) {
|
||
const at = text.indexOf(part);
|
||
assert(at > end, '正文应完整保留并按 A → B → C 排序');
|
||
assert.equal(text.indexOf(part, at + part.length), -1, '同一段正文不能重复');
|
||
end = at + part.length - 1;
|
||
}
|
||
}
|
||
|
||
let passed = 0;
|
||
const failures = [];
|
||
async function check(name, callback) {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-retry-history-'));
|
||
try {
|
||
await callback(dir);
|
||
passed += 1;
|
||
console.log(`通过:${name}`);
|
||
} catch (error) {
|
||
failures.push(name);
|
||
console.error(`失败:${name}:${inspect({ name: error.name, message: error.message.slice(0, 600),
|
||
stack: error.stack?.split('\n').filter(line => /^\s+at /.test(line)).slice(0, 3),
|
||
actual: error.actual, expected: error.expected,
|
||
}, { depth: 1, maxArrayLength: 3, maxStringLength: 160 })}`);
|
||
} finally {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
async function run() {
|
||
if (baselineIndex < 0 && !process.argv.includes('--only-handoff')) {
|
||
for (const startOutcome of ['response', 'timeout', 'exit']) {
|
||
const label = startOutcome === 'response'
|
||
? '取消启动时原生通知先于 RPC 返回,旧 A 不得进入或结束新 B'
|
||
: startOutcome === 'timeout'
|
||
? '取消启动 RPC 超时后保留门禁,晚到 ID 仅中断 A 且 B 正常完成'
|
||
: '取消启动未知 ID 时 app-server 明确退出,提交 A 前文并释放 B 门禁';
|
||
await check(label, async dir => {
|
||
const deferred = () => {
|
||
let resolve, reject;
|
||
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||
return { promise, resolve, reject };
|
||
};
|
||
const aResponse = deferred();
|
||
const bResponse = deferred();
|
||
const nativeStarts = [];
|
||
const nativeInterrupts = [];
|
||
let clientRunning = true;
|
||
const runtimeClient = {
|
||
start: async () => { clientRunning = true; },
|
||
isRunning: () => clientRunning,
|
||
request: async (method, params) => {
|
||
if (method === 'thread/resume') return { thread: { id: params.threadId } };
|
||
if (method === 'turn/interrupt') { nativeInterrupts.push(params); return {}; }
|
||
assert.equal(method, 'turn/start', '夹具只替换线程 RPC 边界');
|
||
nativeStarts.push(params);
|
||
return nativeStarts.length === 1 ? aResponse.promise : bResponse.promise;
|
||
},
|
||
};
|
||
const api = createApi(dir, { realStart: true, runtimeClient });
|
||
const pump = async () => { for (let count = 0; count < 20; count += 1) await Promise.resolve(); };
|
||
// 路由、增量累积、完整运行态提交和完成函数均来自生产代码。
|
||
const receive = notification => {
|
||
const routed = api.findCodexAppEntryByRuntime(notification.params);
|
||
api.handleCodexAppNotification(notification);
|
||
return !!routed;
|
||
};
|
||
const { id, ws } = seed(api, 'cancel-start-race', '启动任务 A');
|
||
await pump();
|
||
assert.equal(nativeStarts.length, 1, 'A 的真实 turn/start RPC 应已发出但尚未返回');
|
||
assert.equal(api.activeCodexAppTurns.get(id).turnId, null);
|
||
if (startOutcome === 'exit') {
|
||
// 延续任务在新原生 ID 确认前已经拥有上轮正文,退出也不能丢弃它。
|
||
const carriedEntry = api.activeCodexAppTurns.get(id);
|
||
carriedEntry.fullText = '已经产生的 A 完整前文';
|
||
api.persistCodexAppTurnState(id, carriedEntry, { immediate: true });
|
||
}
|
||
api.handleAbort(ws, { sessionId: id });
|
||
const nextMessage = { sessionId: id, agent: 'codexapp', clientMessageId: 'race-b-user', text: '新任务 B' };
|
||
const beforeIdentity = api.handleMessage(ws, nextMessage);
|
||
await pump();
|
||
assert.equal(beforeIdentity.ok, false, '旧 A 身份未知时必须阻止同线程 B 开始');
|
||
assert.equal(nativeStarts.length, 1, '取消启动的 A 获取精确身份前不能发第二个 turn/start');
|
||
if (startOutcome !== 'response') {
|
||
aResponse.reject(new Error('夹具:turn/start RPC request timed out'));
|
||
await pump();
|
||
assert.equal(api.activeCodexAppTurns.get(id)?.turnId, null, 'RPC 超时不代表旧 A 没有启动,未知 ID 门禁应保留');
|
||
assert.equal(api.handleMessage(ws, nextMessage).ok, false, 'RPC 超时后 B 仍不能绕过未知身份门禁');
|
||
}
|
||
if (startOutcome === 'exit') {
|
||
clientRunning = false;
|
||
api.handleCodexAppServerExit('fixture-signature', { code: 1, stderr: '夹具:app-server 已明确退出' });
|
||
assert.equal(api.activeCodexAppTurns.has(id), false, 'app-server 明确退出后取消 A 不应永久占用门禁');
|
||
assert.equal(createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant')?.content,
|
||
'已经产生的 A 完整前文', '释放门禁前须提交 A 既有正文');
|
||
assert.equal(nativeInterrupts.length, 0, '已退出的进程无需请求未知 turn interrupt');
|
||
} else {
|
||
receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'inProgress' } } });
|
||
receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-A', itemId: 'agent-A', delta: '旧 A 的迟到正文' } });
|
||
if (startOutcome === 'response') aResponse.resolve({ turn: { id: 'native-A' } });
|
||
await pump();
|
||
assert(nativeInterrupts.some(params => params.turnId === 'native-A'), '旧 A 身份确认后必须按精确回合身份中断');
|
||
}
|
||
assert.equal(api.handleMessage(ws, nextMessage).ok, true, '旧 A 身份处理后 B 可以正常开始');
|
||
await pump();
|
||
assert.equal(nativeStarts.length, 2);
|
||
const bEntry = api.activeCodexAppTurns.get(id);
|
||
receive({ method: 'turn/started', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'inProgress' } } });
|
||
const bText = 'B 第一段完整正文\n' + '乙'.repeat(9000) + '\nB 最后一段完整正文';
|
||
receive({ method: 'item/agentMessage/delta', params: { threadId: 'fixture-thread', turnId: 'native-B', itemId: 'agent-B', delta: bText } });
|
||
if (startOutcome !== 'exit') {
|
||
assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false);
|
||
}
|
||
assert.strictEqual(api.activeCodexAppTurns.get(id), bEntry, '旧 A 的迟到完成不能结束 B');
|
||
assert.equal(bEntry.fullText, bText, 'B 正文不能混入旧 A 通知');
|
||
bResponse.resolve({ turn: { id: 'native-B' } });
|
||
await pump();
|
||
receive({ method: 'item/completed', params: { threadId: 'fixture-thread', turnId: 'native-B', item: { id: 'agent-B', type: 'agentMessage', text: bText } } });
|
||
receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-B', status: 'completed' } } });
|
||
const session = createServerHarness(dir).loadSession(id);
|
||
const replies = session.messages.filter(message => message.role === 'assistant' && message.content.includes('B 第一段完整正文'));
|
||
assert.equal(replies.length, 1, 'B 完成后只能归档一次');
|
||
assert.equal(replies[0].content, bText, 'B 完成与存储重开后前后文完整');
|
||
assert.equal(session.messages.filter(message => message.role === 'user' && message.id === 'race-b-user').length, 1);
|
||
assert.equal(api.activeCodexAppTurns.has(id), false);
|
||
if (startOutcome !== 'exit') {
|
||
assert.equal(receive({ method: 'turn/completed', params: { threadId: 'fixture-thread', turn: { id: 'native-A', status: 'interrupted' } } }), false);
|
||
}
|
||
});
|
||
}
|
||
if (process.argv.includes('--only-start-race')) return;
|
||
}
|
||
|
||
await check('真实自动重试开轮必须继承超过提示和快照上限的 A 正文及工具映射', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
const original = emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
const resumed = api.activeCodexAppTurns.get(id);
|
||
assert(resumed?.fullText.includes(A), '真实 schedule → handleMessage → handleCodexAppMessage 丢失了重试前 A 正文');
|
||
assert.deepEqual(resumed.toolCalls, original.toolCalls, '工具调用不得丢失或只剩预览');
|
||
assert.deepEqual(Array.from(resumed.toolOutputDeltas), Array.from(original.toolOutputDeltas));
|
||
assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(original.agentMessageItems));
|
||
assert.equal(resumed.codexRetry.attempt, 1);
|
||
assert.equal(api.storeApi.loadSession(id).messages.filter(message => message.role === 'user').length, 1);
|
||
});
|
||
if (process.argv.includes('--only-handoff')) return;
|
||
|
||
await check('A → B → C 两次重试后最终助手归档重开仍各保留一次且用户不重复', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
emit(api, id, B, 'B');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
const entry = emit(api, id, C, 'C');
|
||
assert.equal(entry.codexRetry.attempt, 2, '重试次数必须单调增长');
|
||
api.handleCodexAppTurnComplete(id);
|
||
const reloaded = createServerHarness(dir).loadSession(id);
|
||
const assistants = reloaded.messages.filter(message => message.role === 'assistant');
|
||
assert.equal(assistants.length, 1, '一个逻辑任务只保存一个累积助手气泡');
|
||
assertOnceInOrder(assistants[0].content, [A, B, C]);
|
||
assertToolOrder(assistants[0].toolCalls, ['A', 'B', 'C']);
|
||
for (const tool of assistants[0].toolCalls) assert(tool.result.length > 32768, '最终工具结果必须完整');
|
||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1);
|
||
assert.equal(api.storeApi.sessionHistoryStore.loadTurn(id), null, '完成提交后才清理完整运行态');
|
||
});
|
||
|
||
await check('等待重试期间及续跑启动后重连均提供全部已流出的正文与工具', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
const first = emit(api, id, A, 'A');
|
||
api.attachActiveRuntimeToWs({ readyState: 1, label: 'before-error' }, id);
|
||
const originalId = api.sent.findLast(message => message.type === 'resume_generating')?.assistantMessageId;
|
||
assert(originalId, '累积气泡必须有稳定身份');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
assert.equal(api.isSessionRunning(id), true, '等待重试仍属于运行中的逻辑任务');
|
||
api.handleResumeSession({ readyState: 1, label: 'wait-reconnect' }, { sessionId: id, requestId: 'waiting' });
|
||
let resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||
assert.equal(resume?.text, first.fullText, '等待期刷新/重连不能清空或截断正文');
|
||
assert.deepEqual(resume.toolCalls, first.toolCalls);
|
||
assert.equal(resume.assistantMessageId, originalId, '等待期必须复用同一累积气泡身份');
|
||
await api.fireRetry(id);
|
||
emit(api, id, B, 'B');
|
||
assert.equal(api.attachActiveRuntimeToWs({ readyState: 1, label: 'running-reconnect' }, id), true);
|
||
resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||
assertOnceInOrder(resume.text, [A, B]);
|
||
assertToolOrder(resume.toolCalls, ['A', 'B']);
|
||
assert.equal(resume.assistantMessageId, originalId, '新重试 turn 必须复用累积气泡身份');
|
||
api.handleCodexAppTurnComplete(id);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assert.equal(assistant.id, originalId, '归档及重连必须指向同一个助手气泡');
|
||
});
|
||
|
||
await check('等待期完整运行态存储重开保留 A 正文及两个映射', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
const entry = emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assert.equal(state?.fullText, A, '安排重试前不能删除唯一完整运行态');
|
||
assert.deepEqual(state.toolCalls, entry.toolCalls);
|
||
assert.deepEqual(state.toolOutputDeltas, Array.from(entry.toolOutputDeltas));
|
||
assert.deepEqual(state.agentMessageItems, Array.from(entry.agentMessageItems));
|
||
});
|
||
|
||
await check('重试耗尽保存所有已产生正文,且不会再安排新轮', async dir => {
|
||
const api = createApi(dir, { retryConfig: { maxAttempts: 1 } });
|
||
const { id } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
emit(api, id, B, 'B');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||
assert.equal(api.activeCodexAppTurns.has(id), false);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assertOnceInOrder(assistant?.content || '', [A, B]);
|
||
});
|
||
|
||
await check('用户取消等待中的重试仍提交已产生正文', async dir => {
|
||
const api = createApi(dir);
|
||
const { id, ws } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
api.handleAbort(ws, { sessionId: id });
|
||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assert.equal(assistant?.content, A, '取消等待不得删除尚未归档正文');
|
||
});
|
||
|
||
await check('重试前完整运行态保存失败时保留内存与上次落盘正文', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const { id } = seed(api);
|
||
const entry = emit(api, id, A, 'A');
|
||
entry.fullText += '\n最新未落盘正文';
|
||
options.failTurnSave = true;
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assert.equal(state?.fullText, A, '保存失败不能清理此前完整运行态');
|
||
const retained = api.activeCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry || api.retainedCodexAppTurns.get(id);
|
||
assert(retained?.fullText.includes('最新未落盘正文'), '保存失败不能丢弃唯一内存正文');
|
||
});
|
||
|
||
await check('续跑启动失败仍归档此前正文', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const { id } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
options.failStart = true;
|
||
await api.fireRetry(id);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assert.equal(assistant?.content, A);
|
||
});
|
||
|
||
await check('最终会话保存失败仍留下可重开的 A/B/C 完整运行态', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const { id } = seed(api);
|
||
for (const [text, label] of [[A, 'A'], [B, 'B']]) {
|
||
emit(api, id, text, label);
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
}
|
||
emit(api, id, C, 'C');
|
||
options.failSessionSave = true;
|
||
api.handleCodexAppTurnComplete(id);
|
||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assertOnceInOrder(state?.fullText || '', [A, B, C]);
|
||
assertToolOrder(state.toolCalls, ['A', 'B', 'C']);
|
||
assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false);
|
||
});
|
||
|
||
await check('普通新用户轮次从空正文开始而不继承上一气泡', async dir => {
|
||
const api = createApi(dir);
|
||
const { id, ws } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnComplete(id);
|
||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '新任务' }).ok, true);
|
||
const next = api.activeCodexAppTurns.get(id);
|
||
assert.equal(next.fullText, '');
|
||
assert.deepEqual(next.toolCalls, []);
|
||
assert.equal(next.toolOutputDeltas.size, 0);
|
||
assert.equal(next.agentMessageItems.size, 0);
|
||
});
|
||
|
||
await check('跨轮复用原生 item ID 不会覆盖工具或误拼已完成助手消息', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
for (const [text, label] of [[A, 'A'], [B, 'B']]) {
|
||
emit(api, id, text, label, true);
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
}
|
||
const entry = emit(api, id, C, 'C', true);
|
||
assertOnceInOrder(entry.fullText, [A, B, C]);
|
||
assertToolOrder(entry.toolCalls, ['A', 'B', 'C']);
|
||
assert.equal(entry.agentMessageItems.size, 3);
|
||
assert.equal(entry.toolOutputDeltas.size, 3);
|
||
api.handleCodexAppTurnComplete(id);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assertOnceInOrder(assistant.content, [A, B, C]);
|
||
assertToolOrder(assistant.toolCalls, ['A', 'B', 'C']);
|
||
});
|
||
|
||
await check('未完成消息的空白前缀及完整映射跨重试落盘且交接不丢', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
const oldEntry = emit(api, id, A, 'A');
|
||
api.runtime.processCodexAppNotification(oldEntry, { method: 'item/agentMessage/delta',
|
||
params: { threadId: oldEntry.threadId, turnId: oldEntry.turnId, itemId: 'pending-prefix', delta: '\n ' } }, id);
|
||
assert.equal(oldEntry.agentMessagePendingPrefixes.size, 1);
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assert.deepEqual(state?.agentMessagePendingPrefixes, Array.from(oldEntry.agentMessagePendingPrefixes));
|
||
await api.fireRetry(id);
|
||
const resumed = api.activeCodexAppTurns.get(id);
|
||
assert.deepEqual(Array.from(resumed.agentMessagePendingPrefixes), Array.from(oldEntry.agentMessagePendingPrefixes));
|
||
assert.deepEqual(Array.from(resumed.agentMessageItems), Array.from(oldEntry.agentMessageItems));
|
||
});
|
||
|
||
await check('最终保存失败后重连能看完整正文,存储恢复前不得启动新用户轮', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const { id, ws } = seed(api);
|
||
const output = emit(api, id, A, 'A');
|
||
output.fullText += '\n最新完成段';
|
||
options.failSessionSave = true;
|
||
options.failTurnSave = true;
|
||
api.handleCodexAppTurnComplete(id);
|
||
assert(api.retainedCodexAppTurns.get(id)?.fullText.includes('最新完成段'), '双保存失败仍须保留最新内存正文');
|
||
api.attachActiveRuntimeToWs({ readyState: 1 }, id);
|
||
assert.equal(api.sent.findLast(message => message.type === 'resume_generating')?.text, output.fullText);
|
||
assert.equal(api.sent.findLast(message => message.type === 'done')?.historySaved, false);
|
||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, false);
|
||
assert.equal(api.starts.length, 1);
|
||
options.failSessionSave = false;
|
||
options.failTurnSave = false;
|
||
assert.equal(api.handleMessage(ws, { sessionId: id, clientMessageId: 'next-user', text: '下一任务' }).ok, true);
|
||
const reloaded = createServerHarness(dir).loadSession(id);
|
||
assert.equal(reloaded.messages.find(message => message.role === 'assistant')?.content, output.fullText);
|
||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 2);
|
||
assert.equal(api.activeCodexAppTurns.get(id).fullText, '');
|
||
});
|
||
|
||
await check('等待期计时触发时会话暂不可读仍保留 A 的完整运行态及内存', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const { id } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
options.failSessionLoad = true;
|
||
await api.fireRetry(id);
|
||
assert.equal(createServerHarness(dir).sessionHistoryStore.loadTurn(id)?.fullText, A);
|
||
const held = api.retainedCodexAppTurns.get(id) || api.pendingCodexCapacityRetries.get(id)?.entry;
|
||
assert.equal(held?.fullText, A);
|
||
assert.equal(api.starts.length, 1, '会话暂时不可读时不能启动空白新线程');
|
||
});
|
||
|
||
await check('等待期收到新用户任务先提交 A,旧重试取消且新气泡从空开始', async dir => {
|
||
const api = createApi(dir);
|
||
const { id, ws } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const oldTimer = api.pendingCodexCapacityRetries.get(id)?.timer;
|
||
assert(oldTimer);
|
||
assert.equal(api.handleMessage(ws, { sessionId: id, text: '新用户任务', clientMessageId: 'new-during-wait' }).ok, true);
|
||
assert.equal(api.pendingCodexCapacityRetries.has(id), false);
|
||
assert.equal(api.activeCodexAppTurns.get(id).fullText, '');
|
||
const reloaded = createServerHarness(dir).loadSession(id);
|
||
assert.deepEqual(reloaded.messages.map(message => message.role), ['user', 'assistant', 'user']);
|
||
assert.equal(reloaded.messages[1].content, A);
|
||
oldTimer.callback();
|
||
await Promise.resolve();
|
||
assert.equal(api.starts.length, 2, '已取消的计时器不能再启动旧任务');
|
||
});
|
||
|
||
await check('超过映射及工具预览数量的 105 项完整状态跨重试与归档保留', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
const first = api.activeCodexAppTurns.get(id);
|
||
const base = { threadId: first.threadId, turnId: first.turnId };
|
||
for (let index = 0; index < 105; index += 1) {
|
||
api.runtime.processCodexAppNotification(first, { method: 'item/agentMessage/delta',
|
||
params: { ...base, itemId: 'agent-many-' + index, delta: '正文段落-' + index } }, id);
|
||
api.runtime.processCodexAppNotification(first, { method: 'item/started',
|
||
params: { ...base, item: { id: 'tool-many-' + index, type: 'commandExecution', command: 'fixture-' + index } } }, id);
|
||
api.runtime.processCodexAppNotification(first, { method: 'item/commandExecution/outputDelta',
|
||
params: { ...base, itemId: 'tool-many-' + index, delta: '输出-' + index } }, id);
|
||
}
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const state = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assert.equal(state?.toolCalls.length, 105);
|
||
assert.equal(state.toolOutputDeltas.length, 105);
|
||
assert.equal(state.agentMessageItems.length, 105);
|
||
await api.fireRetry(id);
|
||
const resumed = api.activeCodexAppTurns.get(id);
|
||
assert.equal(resumed.toolCalls.length, 105);
|
||
assert.equal(resumed.toolOutputDeltas.size, 105);
|
||
assert.equal(resumed.agentMessageItems.size, 105);
|
||
api.attachActiveRuntimeToWs({ readyState: 1 }, id);
|
||
assert.equal(api.sent.findLast(message => message.type === 'resume_generating').toolCalls.length, 105);
|
||
api.handleCodexAppTurnComplete(id);
|
||
const assistant = createServerHarness(dir).loadSession(id).messages.find(message => message.role === 'assistant');
|
||
assert.equal(assistant.toolCalls.length, 105);
|
||
assert.equal(assistant.content, first.fullText);
|
||
});
|
||
|
||
await check('等待期进程重开从完整运行态恢复 A/B,一次归档且重复恢复不再追加', async dir => {
|
||
const api = createApi(dir);
|
||
const { id } = seed(api);
|
||
emit(api, id, A, 'A');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
await api.fireRetry(id);
|
||
emit(api, id, B, 'B');
|
||
api.handleCodexAppTurnFailure(id, new Error(ERROR));
|
||
const savedState = createServerHarness(dir).sessionHistoryStore.loadTurn(id);
|
||
assertOnceInOrder(savedState?.fullText || '', [A, B]);
|
||
const reopened = createApi(dir);
|
||
assert.equal(reopened.recoverCodexAppTurnState(id), true);
|
||
let session = createServerHarness(dir).loadSession(id);
|
||
let assistants = session.messages.filter(message => message.role === 'assistant');
|
||
assert.equal(assistants.length, 1);
|
||
assertOnceInOrder(assistants[0].content, [A, B]);
|
||
assertToolOrder(assistants[0].toolCalls, ['A', 'B']);
|
||
assert.equal(assistants[0].id, savedState.assistantMessageId);
|
||
assert.equal(assistants[0].interrupted, true);
|
||
assert.equal(session.messages.filter(message => message.role === 'user').length, 1);
|
||
// 模拟运行态清理失败后下次重启再次读到同一状态,恢复入口必须幂等。
|
||
reopened.storeApi.sessionHistoryStore.saveTurn(id, savedState);
|
||
assert.equal(reopened.recoverCodexAppTurnState(id), true);
|
||
session = createServerHarness(dir).loadSession(id);
|
||
assistants = session.messages.filter(message => message.role === 'assistant');
|
||
assert.equal(assistants.length, 1);
|
||
assert.equal(reopened.storeApi.sessionHistoryStore.loadTurn(id), null);
|
||
});
|
||
|
||
if (baselineIndex >= 0 || process.argv.includes('--skip-mock')) return;
|
||
await check('真实 mock 协议三轮续接:A/B/C 正文、复用 ID 工具及用户归档完整', async dir => {
|
||
const options = {};
|
||
const api = createApi(dir, options);
|
||
const client = createCodexAppServerClient({ command: process.execPath,
|
||
args: [path.join(__dirname, 'mock-codex-app-server.js'), 'app-server'], cwd: dir,
|
||
env: { ...process.env, CODEX_HOME: path.join(dir, 'codex-home') },
|
||
onNotification: notification => {
|
||
const entry = api.activeCodexAppTurns.get('mock-history');
|
||
if (!entry) return;
|
||
const result = api.runtime.processCodexAppNotification(entry, notification, 'mock-history');
|
||
if (result.done) api.handleCodexAppTurnComplete('mock-history');
|
||
},
|
||
});
|
||
const waitFor = async predicate => {
|
||
const deadline = Date.now() + 5000;
|
||
while (!predicate()) {
|
||
assert(Date.now() < deadline, '隔离 mock 事件等待超过 5 秒');
|
||
await new Promise(resolve => setTimeout(resolve, 10));
|
||
}
|
||
};
|
||
try {
|
||
await client.start();
|
||
await client.request('thread/resume', { threadId: 'fixture-thread' });
|
||
options.startTurn = async (sessionId, input) => {
|
||
await client.request('turn/start', { threadId: 'fixture-thread', input });
|
||
};
|
||
const { id } = seed(api, 'mock-history', 'codexapp retry full history');
|
||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||
await waitFor(() => api.pendingCodexCapacityRetries.has(id));
|
||
assert.equal(api.pendingCodexCapacityRetries.get(id).attempts, attempt);
|
||
api.handleResumeSession({ readyState: 1 }, { sessionId: id });
|
||
const resume = api.sent.findLast(message => message.type === 'resume_generating');
|
||
assert(resume.text.includes('retry-history-A-begin'));
|
||
await api.fireRetry(id);
|
||
}
|
||
await waitFor(() => !api.activeCodexAppTurns.has(id) && !api.pendingCodexCapacityRetries.has(id));
|
||
const reloaded = createServerHarness(dir).loadSession(id);
|
||
const assistant = reloaded.messages.find(message => message.role === 'assistant');
|
||
for (const label of ['A', 'B', 'C']) {
|
||
assert.equal(assistant.content.split(`retry-history-${label}-begin`).length, 2);
|
||
assert.equal(assistant.content.split(`retry-history-${label}-end`).length, 2);
|
||
}
|
||
assert.equal(assistant.toolCalls.length, 3);
|
||
assert.equal(new Set(assistant.toolCalls.map(tool => tool.id)).size, 3);
|
||
['A', 'B', 'C'].forEach((label, index) => {
|
||
assert(assistant.toolCalls[index].result.startsWith(`tool-${label}-full-result`));
|
||
assert(assistant.toolCalls[index].result.length > 32768);
|
||
});
|
||
assert.equal(reloaded.messages.filter(message => message.role === 'user').length, 1);
|
||
} finally {
|
||
await client.stop();
|
||
}
|
||
});
|
||
}
|
||
|
||
run().then(() => {
|
||
if (failures.length) {
|
||
console.error(`运行中气泡续接回归失败:${failures.length} 项;通过 ${passed} 项`);
|
||
process.exitCode = 1;
|
||
} else console.log(`运行中气泡续接回归通过:${passed} 项`);
|
||
}).catch(error => { console.error(error); process.exitCode = 1; });
|