修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
347
scripts/codexapp-steer-recovery-unit.js
Normal file
347
scripts/codexapp-steer-recovery-unit.js
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user