348 lines
15 KiB
JavaScript
348 lines
15 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 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;
|
||
});
|