Files
cc-web/scripts/codexapp-steer-recovery-unit.js
shiyue 69b6db0f5b 修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
2026-10-10 23:40:16 +08:00

348 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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;
});