#!/usr/bin/env node 'use strict'; const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); // 直接读取服务端函数,避免单测中的业务副本与实际实现逐渐偏离。 const serverPath = path.join(__dirname, '..', 'server.js'); const serverSource = fs.readFileSync(serverPath, 'utf8'); const functionNames = [ 'codexAppAssistantMessageIdForEntry', 'ensureCodexAppAssistantMessageId', 'buildCodexAppResumePayload', 'retireCodexAppRuntimeTurn', 'handleMessage', 'handleCodexAppMessage', 'handleCodexAppTurnComplete', 'isCodexAppNoActiveTurnError', 'rollbackPersistedCodexAppSteerMessage', 'reorderStaleCodexAppTurnMessages', 'handleCodexAppSteerMessage', ]; function extractFunction(name) { const match = new RegExp(`^function ${name}\\(`, 'm').exec(serverSource); assert.ok(match, `服务端应存在实际函数:${name}`); const closingLine = /\n\}/g; closingLine.lastIndex = match.index; let end; while ((end = closingLine.exec(serverSource))) { const candidate = serverSource.slice(match.index, end.index + 2); try { // 编译成单一表达式,防止误带相邻函数或全局初始化代码。 new vm.Script(`(${candidate})`, { filename: serverPath }); return candidate; } catch (error) { if (!(error instanceof SyntaxError)) throw error; } } assert.fail(`无法完整提取服务端函数:${name}`); } const sourceFunctions = functionNames.map(extractFunction).join('\n'); const copy = (value) => JSON.parse(JSON.stringify(value)); const noop = () => {}; const sessionId = 'steer-recovery-review'; const answerId = 'form-answer-review'; const answerText = '表单答案:保留这条消息'; const oldOutput = '上一轮完整输出,不能被新轮覆盖。'; const noActiveErrors = [ 'no active turn to steer', 'expected active turn id `old-turn-review`: no active turn', ]; function deferred() { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function createHarness({ running = true, failedSaveAttempts = [], steerError = null } = {}) { const ws = {}; const timers = new Map(); const events = []; const requests = []; const starts = []; const saves = []; const stateWrites = []; const failedSaves = new Set(failedSaveAttempts); const requestResult = deferred(); const oldEntry = { ws, threadId: 'thread-review', turnId: 'old-turn-review', fullText: oldOutput, toolCalls: [], mcpContext: { sourceSessionId: sessionId }, startedAt: '2026-10-10T00:00:00.000Z', }; let storedSession = { id: sessionId, agent: 'codexapp', title: '恢复插入单测', cwd: '/tmp/ccweb-steer-recovery-unit', permissionMode: 'yolo', codexAppThreadId: 'thread-review', messages: [{ id: 'original-question', role: 'user', content: '上一轮问题', timestamp: '2026-10-10T00:00:00.000Z', }], }; let retainedState = copy(oldEntry); const writeState = (entry) => { retainedState = copy(entry); stateWrites.push(copy(entry)); return true; }; const context = { crypto, Date, Promise, Map, activeCodexAppTurns: new Map([[sessionId, oldEntry]]), activeCodexAppGoalCommands: new Map(), activeCodexAppCompactions: new Map(), codexAppGoalStates: new Map(), activeProcesses: new Map(), pendingCodexCapacityRetries: new Map(), retainedCodexAppTurns: new Map(), retiredCodexAppRuntimeTurns: new Map(), giteaWorkflowWaiters: new Map(), wsSessionMap: new Map(), // 手动推进轮询定时器,不等待真实时间,也不向磁盘写会话。 setTimeout(callback, delay) { const token = { unref: noop }; timers.set(token, { callback, delay }); return token; }, clearTimeout: (token) => timers.delete(token), loadSession: (id) => id === sessionId ? copy(storedSession) : null, saveSession(session) { saves.push(copy(session)); if (failedSaves.has(saves.length)) return false; storedSession = copy(session); return true; }, writeCodexAppTurnState: (id, entry) => writeState(entry), persistCodexAppTurnState: (id, entry) => writeState(entry), cleanupCodexAppTurnState(id, entry) { entry.codexAppStateCleaned = true; retainedState = null; }, codexAppClient: { isRunning: () => running, request(method, params, timeout) { requests.push({ method, params: copy(params), timeout }); return steerError ? Promise.reject(new Error(steerError)) : requestResult.promise; }, }, startCodexAppTurn(id, input) { starts.push({ sessionId: id, input: copy(input) }); return Promise.resolve(); }, sanitizeId: (id) => id, isCodexAppSession: (session) => session?.agent === 'codexapp', getRuntimeSessionId: (session) => session.codexAppThreadId, getDefaultSessionCwd: () => '/tmp/ccweb-steer-recovery-unit', getSessionAgent: (session) => session.agent, normalizeSession: noop, normalizeGiteaSource: () => null, resolveMessageAttachments: (attachments) => copy(attachments), resolveComposerDecorators: (text) => ({ runtimeText: text, mentions: [] }), codexAppInputFromMessage: (text, attachments) => [ ...(text.trim() ? [{ type: 'text', text }] : []), ...attachments.map((attachment) => ({ type: 'image', id: attachment.id })), ], attachClientRequestId: (payload) => payload, detachWsFromActiveRuntimes: noop, registerCrossConversationReplyRequest: noop, unregisterCrossConversationReplyRequest: noop, deletePendingCrossConversationReply: noop, resolvePendingCodexAppUserInputsForSession: noop, resolvePendingCodexAppApprovalsForSession: noop, resolvePendingCodexAppElicitationsForSession: noop, formatRuntimeError: (agent, text) => String(text), codexAppTurnKey: (id, entry) => `${entry.threadId}:${entry.turnId}`, shouldRetryCodexTransientFailure: () => false, cancelCodexCapacityRetry: noop, hasCodexAppTurnMessage: (session, key) => session.messages.some((message) => message.codexAppTurnKey === key), ensureStableMessageId: (message) => ({ ...message, id: crypto.randomUUID() }), isCodexAppGoalActive: () => false, dispatchTaskBoardLifecycle: noop, TASK_BOARD_LIFECYCLE_EVENTS: { USER_MESSAGE_RECEIVED: 'user_message_received', TURN_FAILED: 'turn_failed', TURN_COMPLETED: 'turn_completed', }, TASK_STATUS_CLASSIFICATION_EVENTS: { USER_MESSAGE_RECEIVED: 'user_message_received', TURN_COMPLETED: 'turn_completed', }, enqueueTaskStatusClassification: () => Promise.resolve(), crossConversationReplyRequestIds: () => [], reconcilePendingCrossConversationReplies: noop, flushPendingCrossConversationReplies: noop, sanitizeMessageForTransport: copy, resolveSessionHistory: (session) => session, plog: noop, sendSessionList: noop, previewInlineText: (text) => text, wsSend: (target, payload) => events.push(copy(payload)), }; vm.createContext(context); vm.runInContext(sourceFunctions, context, { filename: serverPath, timeout: 1000 }); return { context, oldEntry, events, requests, starts, saves, stateWrites, requestResult, session: () => copy(storedSession), state: () => retainedState && copy(retainedState), send(attachments = []) { return context.handleCodexAppSteerMessage(ws, { sessionId, clientMessageId: answerId, text: answerText, attachments, }, { runtimeText: answerText }); }, complete: () => context.handleCodexAppTurnComplete(sessionId), setRunning: (value) => { running = value; }, async poll() { const waiting = [...timers.entries()].filter(([, timer]) => timer.delay === 50); assert.equal(waiting.length, 1, '等待 ready 时应只有一个待执行的轮询'); const [token, timer] = waiting[0]; timers.delete(token); timer.callback(); await new Promise(setImmediate); }, drain: () => new Promise(setImmediate), }; } function answers(harness) { return harness.session().messages.filter((message) => message.id === answerId); } function assertSaveFailure(harness) { assert.equal(harness.starts.length, 0, '保存失败不能启动新轮'); assert.equal(harness.stateWrites.filter((entry) => entry.fullText === '').length, 0, '保存失败不能写入新轮的空运行态'); assert.ok(harness.events.some((event) => event.type === 'error' && event.code === 'session_save_failed'), '保存失败应返回明确的可重试错误'); assert.equal(harness.events.filter((event) => event.type === 'codex_app_steer_status').at(-1)?.status, 'failed', '保存失败不能把答案确认成发送成功'); } async function testWaitingTurnEnded() { const harness = createHarness({ running: false }); const attachment = { id: 'answer-image', filename: '答案图片.png', mime: 'image/png', kind: 'image' }; assert.equal(harness.send([attachment]).ok, true, '等待中的插入请求应被接收'); assert.equal(answers(harness).length, 0, '等待 ready 时不能预先重复保存答案'); harness.complete(); harness.setRunning(true); await harness.poll(); assert.equal(harness.requests.length, 0, '旧轮已结束时应走普通消息分发'); assert.equal(harness.starts.length, 1, '旧轮结束后应只启动一轮普通发送'); assert.equal(answers(harness).length, 1, '重分发后答案应只保存一次'); assert.deepEqual(answers(harness)[0].attachments, [attachment], '转新轮必须保留答案附件'); assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'assistant', 'user'], '历史应先保存旧轮输出,再保存新答案'); assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'sent'), '转新轮后应确认同一条答案发送成功'); } async function testWaitingTurnReplaced() { const harness = createHarness({ running: false }); harness.send(); harness.complete(); const replacement = { ...harness.oldEntry, turnId: 'replacement-turn-review', fullText: '', historySaved: undefined }; harness.context.activeCodexAppTurns.set(sessionId, replacement); harness.setRunning(true); await harness.poll(); assert.equal(harness.requests.length, 1, '已有新 entry 时应向当前轮发送一次插入请求'); assert.equal(harness.requests[0].method, 'turn/steer', '已有活动轮应保留插入语义'); assert.equal(harness.requests[0].params.expectedTurnId, replacement.turnId, '重新分发必须使用新 entry 的 turn id'); assert.equal(harness.starts.length, 0, '已有新 entry 时不能额外创建新轮'); assert.equal(answers(harness).length, 1, '重新分发到新 entry 时答案不能重复保存'); harness.requestResult.resolve({}); await harness.drain(); assert.ok(harness.events.some((event) => event.type === 'codex_app_steer_status' && event.status === 'inserted'), '当前轮插入成功后应确认答案'); } async function testEndedTurnSaveFailed() { const harness = createHarness({ failedSaveAttempts: [2], steerError: noActiveErrors[1] }); harness.send(); await harness.drain(); assertSaveFailure(harness); assert.equal(harness.oldEntry.historySaved, false, '旧轮保存失败应记录明确状态'); assert.equal(harness.state()?.fullText, oldOutput, '旧轮完整输出运行态必须保留'); assert.equal(harness.state()?.turnStatus, 'completed', '保留的运行态应标记旧轮已经结束'); assert.equal(answers(harness).length, 1, '旧轮输出保存失败时不能删除已保存的答案'); } async function testCompletedFirstReorderSaveFailed() { const harness = createHarness({ failedSaveAttempts: [3] }); harness.send(); await harness.drain(); assert.equal(harness.requests.length, 1, '应先发出等待响应的 steer 请求'); harness.complete(); assert.equal(harness.oldEntry.historySaved, true, '先到的 completion 应成功保存旧轮输出'); harness.requestResult.reject(new Error(noActiveErrors[0])); await harness.drain(); assertSaveFailure(harness); assert.equal(harness.saves.length, 3, '应真实尝试保存旧轮输出与答案的重排顺序'); assert.equal(answers(harness).length, 1, '历史重排保存失败时应保留一次答案'); assert.deepEqual(harness.session().messages.map((message) => message.role), ['user', 'user', 'assistant'], '保存失败时不能误认为重排已经提交'); } async function testErrorClassification() { const harness = createHarness(); for (const message of noActiveErrors) { assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), true, `应识别真实的无活动轮错误:${message}`); } const otherErrors = [ 'expected active turn id `old-turn-review`: active turn id `different-turn`', 'network connection reset', 'network error while checking no active turn to steer', ]; for (const message of otherErrors) { assert.equal(harness.context.isCodexAppNoActiveTurnError(new Error(message)), false, `不能把其他错误误判成结束:${message}`); const failedHarness = createHarness({ steerError: message }); failedHarness.send(); await failedHarness.drain(); assert.equal(failedHarness.starts.length, 0, `真实错误不能自动开新轮:${message}`); assert.equal(failedHarness.context.activeCodexAppTurns.get(sessionId), failedHarness.oldEntry, '真实错误不能提前收敛仍活动的旧轮'); assert.equal(answers(failedHarness).length, 0, '未被运行时接收的答案应回滚,避免重试时重复保存'); assert.ok(failedHarness.events.some((event) => event.type === 'error' && event.code === 'codexapp_steer_failed'), '真实错误应保留插入失败语义'); } } async function main() { const tests = [ ['等待 ready 时旧轮结束后普通发送', testWaitingTurnEnded], ['等待 ready 时已有新 entry 按当前轮插入', testWaitingTurnReplaced], ['无活动轮但旧轮输出保存失败时保留运行态', testEndedTurnSaveFailed], ['completion 先到而重排保存失败时保留答案', testCompletedFirstReorderSaveFailed], ['无活动轮错误与真实错误区分', testErrorClassification], ]; // 独立脚本异常挂起时也会在 60 秒以内退出。 const deadline = setTimeout(() => { console.error('codexapp-steer-recovery-unit:单测超过 45 秒,已终止。'); process.exit(1); }, 45000); try { for (const [name, test] of tests) { await test(); console.log(`通过:${name}`); } console.log(`codexapp-steer-recovery-unit:${tests.length} 项全部通过。`); } finally { clearTimeout(deadline); } } main().catch((error) => { console.error(error.stack || error); process.exitCode = 1; });