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

1010 lines
51 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';
// 该回归抽取 server.js 的真实完成/恢复函数,配真实 session-history-store 与临时目录执行。
// 只读写临时夹具,不接触真实会话、不启动外部进程、不复用镜像实现。
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { inspect } = require('node:util');
const { createAgentRuntime } = require('../lib/agent-runtime');
const { createServerHarness } = require('./session-history-store-unit');
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 { CCWEB_REPLY_MODES } = require('../lib/ccweb-mcp-server');
const REPO_DIR = path.join(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
let serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
function extractFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `server.js 应定义 ${name}`);
let parenDepth = 0;
let signatureEnd = -1;
for (let index = start; index < source.length; index += 1) {
if (source[index] === '(') parenDepth += 1;
if (source[index] === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnd = index;
break;
}
}
}
assert(signatureEnd > start, `${name} 应有完整签名`);
const open = source.indexOf('{', signatureEnd);
assert(open > start, `${name} 应有函数体`);
let depth = 0;
for (let index = open; index < source.length; index += 1) {
if (source[index] === '{') depth += 1;
if (source[index] === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, index + 1);
}
}
throw new Error(`${name} 函数体不完整`);
}
function extractConstSource(source, name) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = source.match(new RegExp(`^const ${escapedName} = .*;$`, 'm'));
assert(match, `server.js 应定义单行常量 ${name}`);
return match[0];
}
function entryFixture(overrides = {}) {
return {
agent: 'codex',
pid: 999999,
ws: null,
fullText: '',
toolCalls: [],
lastCost: null,
lastUsage: null,
lastError: null,
errorSent: false,
tailer: null,
runKey: null,
retryRequest: null,
...overrides,
};
}
function sessionFixture(id, agent, messages = []) {
const now = new Date(1800000000000).toISOString();
return {
id,
title: '夹具会话',
titleSource: 'derived',
created: now,
updated: now,
pinnedAt: null,
agent,
claudeSessionId: null,
codexThreadId: null,
codexAppThreadId: null,
model: agent === 'claude' ? null : 'gpt-5-codex',
permissionMode: 'yolo',
totalCost: 0,
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
messages,
cwd: '',
};
}
// —— CLI 完成与恢复 ——
function createCliCompletionApi(dir, options = {}) {
const runtimeFs = options.fs || fs;
const storeApi = createServerHarness(dir, { fs: options.storeFs || fs });
const logs = [];
const sent = [];
const dispatches = [];
const activeProcesses = new Map();
const alivePids = new Set(options.alivePids || []);
const pendingSlashCommands = new Map();
const pendingCompactRetries = new Map();
const loadSession = options.loadSession || storeApi.loadSession;
const saveSession = options.saveSession || storeApi.saveSession;
const agentRuntime = createAgentRuntime({
processEnv: {},
wsSend: (target, payload) => { sent.push(payload); },
sanitizeToolInput: value => value,
loadSession,
saveSession,
});
const api = new Function(
'fs', 'path', 'crypto', 'dir', 'sessionHistoryStore', 'activeProcesses', 'pendingSlashCommands',
'pendingCompactRetries', 'wsSend', 'plog', 'loadSession', 'saveSession', 'getSessionAgent',
'isProcessRunning', 'cleanRunDir', 'processRuntimeEvent', 'FileTailer', 'codexAppStatePath',
'recoverCodexAppTurnState', 'sendSessionList', 'cancelCodexCapacityRetry', 'scheduleCodexCapacityRetry',
'shouldRetryCodexTransientFailure', 'crossConversationReplyRequestIds', 'completeCrossConversationReply',
'flushPendingCrossConversationReplies', 'dispatchTaskBoardLifecycle', 'wss', 'loadNotifyConfig',
'buildNotifyContent', 'sendNotification', 'TASK_BOARD_LIFECYCLE_EVENTS', `
const SESSIONS_DIR = dir;
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
${[
'sanitizeId',
'runDir',
'writeFileAtomicSync',
'hasRuntimeOutput',
'codexOutputContent',
'cliRunKey',
'hasCliRunMessage',
'isContextLimitError',
'handleProcessComplete',
'recoverProcesses',
].filter(name => serverSource.includes('function ' + name + '(')).map(name => extractFunctionSource(serverSource, name)).join('\n')}
return {
handleProcessComplete, recoverProcesses, hasRuntimeOutput, runDir, sanitizeId,
codexOutputContent: typeof codexOutputContent === 'function' ? codexOutputContent : null,
cliRunKey: typeof cliRunKey === 'function' ? cliRunKey : null,
};
`,
)(
runtimeFs,
path,
crypto,
dir,
storeApi.sessionHistoryStore,
activeProcesses,
pendingSlashCommands,
pendingCompactRetries,
(target, payload) => { sent.push(payload); },
(level, event, data) => { logs.push({ level, event, ...data }); },
loadSession,
saveSession,
session => session?.agent || 'claude',
pid => alivePids.has(pid),
sessionId => {
const runPath = path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9\-]/g, '')}-run`);
if (options.failCleanup) {
const error = new Error('模拟清理失败');
error.code = 'EACCES';
throw error;
}
if (runtimeFs.existsSync(runPath)) runtimeFs.rmSync(runPath, { recursive: true });
},
(target, event, sessionId) => agentRuntime.processRuntimeEvent(target, event, sessionId),
class FileTailer { constructor() {} readNew() {} stop() {} start() {} },
sessionId => path.join(dir, `${String(sessionId).replace(/[^a-zA-Z0-9\-]/g, '')}-run`, 'codexapp-state.json'),
() => false,
() => {},
() => true,
() => false,
() => false,
() => [],
() => false,
() => false,
(sessionId, event) => { dispatches.push({ sessionId, ...event }); return Promise.resolve(null); },
{ clients: [] },
() => ({ provider: 'off' }),
async () => ({ title: '', content: '' }),
() => {},
TASK_BOARD_LIFECYCLE_EVENTS,
);
return { ...api, storeApi, logs, sent, dispatches, activeProcesses };
}
function writeRunArtifacts(dir, sessionId, { outputLines = [], runKey = null, pid = 999999 } = {}) {
const runPath = path.join(dir, `${sessionId}-run`);
fs.mkdirSync(runPath, { recursive: true });
if (runKey) fs.writeFileSync(path.join(runPath, 'run-key'), runKey);
fs.writeFileSync(path.join(runPath, 'pid'), String(pid));
if (outputLines.length > 0) {
fs.writeFileSync(path.join(runPath, 'output.jsonl'), outputLines.map(line => JSON.stringify(line)).join('\n') + '\n');
}
return runPath;
}
// —— Codex App 完整运行态 ——
function createCodexAppStateApi(dir, options = {}) {
const storeApi = createServerHarness(dir);
const logs = [];
const sent = [];
const activeCodexAppTurns = new Map();
const realLoadSession = storeApi.loadSession;
const realSaveSession = storeApi.saveSession;
const runtimeFs = options.fs || fs;
const api = new Function(
'fs', 'path', 'crypto', 'dir', 'sessionHistoryStore', 'activeCodexAppTurns', 'plog',
'loadSession', 'saveSession', 'isCodexAppSession', 'getSessionAgent', 'setRuntimeSessionId',
'recoverCcwebMcpChildThreadsFromPersistedToolCalls', 'wsSend', 'resolvePendingCodexAppUserInputsForSession',
'resolvePendingCodexAppApprovalsForSession', 'resolvePendingCodexAppElicitationsForSession',
'formatRuntimeError', 'shouldRetryCodexTransientFailure', 'cancelCodexCapacityRetry',
'ensureStableMessageId', 'dispatchTaskBoardLifecycle', 'enqueueTaskStatusClassification',
'crossConversationReplyRequestIds', 'completeCrossConversationReply', 'flushPendingCrossConversationReplies',
'isCodexAppGoalActive', 'resolveSessionHistory', 'sanitizeMessageForTransport', 'sendSessionList',
'broadcastSessionList', 'wss', 'buildNotifyContent', 'sendNotification', 'giteaWorkflowWaiters',
'settleGiteaWorkflowTurn', 'TASK_BOARD_LIFECYCLE_EVENTS', 'TASK_STATUS_CLASSIFICATION_EVENTS', `
const SESSIONS_DIR = dir;
const retainedCodexAppTurns = new Map();
const retiredCodexAppRuntimeTurns = new Map();
${[
'readPositiveIntEnv',
'sanitizeId',
'runDir',
'writeFileAtomicSync',
'cleanRunDir',
'textByteLength',
'truncateTextValue',
'sanitizePersistValue',
'sanitizeToolCallsForPersist',
'mapToPairs',
'codexAppStatePath',
'codexAppTurnKey',
'codexAppAssistantMessageIdForEntry',
'ensureCodexAppAssistantMessageId',
'buildCodexAppResumePayload',
'retireCodexAppRuntimeTurn',
'serializeCodexAppEntry',
'buildCodexAppStateJson',
'writeCodexAppTurnState',
'loadCodexAppTurnState',
'cleanupCodexAppTurnState',
'hasCodexAppTurnMessage',
'appendCodexAppRecoveryNotice',
'recoverCodexAppTurnState',
'handleCodexAppTurnComplete',
].map(name => extractFunctionSource(serverSource, name)).join('\n')}
${[
'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',
'SESSION_MESSAGE_CONTENT_MAX_CHARS',
'SESSION_TOOL_INPUT_MAX_CHARS',
'SESSION_TOOL_RESULT_MAX_CHARS',
'SESSION_MAX_TOOL_CALLS_PER_MESSAGE',
'PERSIST_TRUNCATED_TEXT',
].map(name => extractConstSource(serverSource, name)).join('\n')}
return {
writeCodexAppTurnState, loadCodexAppTurnState, recoverCodexAppTurnState,
cleanupCodexAppTurnState, handleCodexAppTurnComplete, serializeCodexAppEntry, codexAppTurnKey,
};
`,
)(
runtimeFs,
path,
crypto,
dir,
storeApi.sessionHistoryStore,
activeCodexAppTurns,
(level, event, data) => { logs.push({ level, event, ...data }); },
options.loadSession || realLoadSession,
options.saveSession || realSaveSession,
session => session?.agent === 'codexapp',
session => session?.agent || 'claude',
(session, runtimeId) => { session.codexAppThreadId = runtimeId; },
() => {},
(target, payload) => { sent.push(payload); },
() => {},
() => {},
() => {},
(agent, raw) => String(raw || ''),
() => false,
() => true,
message => message,
() => Promise.resolve(null),
() => Promise.resolve(null),
() => [],
() => false,
() => false,
() => false,
session => session,
message => message,
() => {},
() => {},
{ clients: [] },
async () => ({ title: '', content: '' }),
() => {},
new Map(),
() => {},
TASK_BOARD_LIFECYCLE_EVENTS,
TASK_STATUS_CLASSIFICATION_EVENTS,
);
return { ...api, storeApi, logs, sent, activeCodexAppTurns };
}
// —— 初次消息与 steer 保存失败 ——
function createMessageSaveFailureApi(dir, options = {}) {
const storeApi = createServerHarness(dir);
const sent = [];
const maps = {
activeCodexAppTurns: new Map(),
activeCodexAppGoalCommands: new Map(),
activeCodexAppCompactions: new Map(),
codexAppGoalStates: new Map(),
activeProcesses: new Map(),
pendingCompactRetries: new Map(),
pendingCodexCapacityRetries: new Map(),
retainedCodexAppTurns: new Map(),
wsSessionMap: new Map(),
};
const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'maps', 'options', 'sent', `
const wsSend = (ws, payload) => sent.push(payload);
const attachClientRequestId = (payload, source) => ({ ...payload, requestId: source?.requestId });
const resolveMessageAttachments = () => [];
const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] });
const cancelCodexCapacityRetry = () => true;
const isCodexAppGoalActive = () => false;
const reconcilePendingCrossConversationReplies = () => false;
const flushPendingCrossConversationReplies = () => false;
const normalizeSession = session => session;
const getRuntimeSessionId = () => null;
const isCodexAppSession = session => session?.agent === 'codexapp';
const getSessionAgent = session => session?.agent || 'claude';
const getDefaultSessionCwd = () => dir;
const normalizeGiteaSource = value => value || null;
const dispatchTaskBoardLifecycle = () => Promise.resolve(null);
const sendSessionList = () => {};
const loadSession = storeApi.loadSession;
const saveSession = options.saveSession;
const activeCodexAppTurns = maps.activeCodexAppTurns;
const activeCodexAppGoalCommands = maps.activeCodexAppGoalCommands;
const activeCodexAppCompactions = maps.activeCodexAppCompactions;
const codexAppGoalStates = maps.codexAppGoalStates;
const activeProcesses = maps.activeProcesses;
const pendingCompactRetries = maps.pendingCompactRetries;
const pendingCodexCapacityRetries = maps.pendingCodexCapacityRetries;
const retainedCodexAppTurns = maps.retainedCodexAppTurns;
const wsSessionMap = maps.wsSessionMap;
${extractFunctionSource(serverSource, 'handleMessage')}
return { handleMessage };
`)(fs, path, crypto, dir, storeApi, maps, options, sent);
return { ...api, storeApi, sent, maps };
}
function createSteerSaveFailureApi(dir) {
const storeApi = createServerHarness(dir);
const sent = [];
const requestCalls = [];
const maps = { activeCodexAppTurns: new Map(), wsSessionMap: new Map() };
const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'maps', 'sent', 'requestCalls', `
const wsSend = (ws, payload) => sent.push(payload);
const sanitizeId = value => String(value || '').replace(/[^a-zA-Z0-9\\-]/g, '');
const resolveMessageAttachments = () => [];
const resolveComposerDecorators = text => ({ runtimeText: text, mentions: [] });
const registerCrossConversationReplyRequest = () => {};
const unregisterCrossConversationReplyRequest = () => {};
const deletePendingCrossConversationReply = () => {};
const detachWsFromActiveRuntimes = () => {};
const codexAppInputFromMessage = text => ({ text });
const codexAppClient = {
isRunning: () => true,
request: (...args) => { requestCalls.push(args); return Promise.resolve({}); },
};
const enqueueTaskStatusClassification = () => Promise.resolve(null);
const previewInlineText = text => String(text || '');
const isCodexAppSession = session => session?.agent === 'codexapp';
const sendSessionList = () => {};
const isCodexAppNoActiveTurnError = () => false;
const rollbackPersistedCodexAppSteerMessage = () => false;
const handleCodexAppTurnComplete = () => {};
const handleCodexAppMessage = () => null;
const formatRuntimeError = (agent, raw) => String(raw || '');
const getSessionAgent = session => session?.agent || 'claude';
const loadSession = storeApi.loadSession;
const saveSession = () => false;
const activeCodexAppTurns = maps.activeCodexAppTurns;
const wsSessionMap = maps.wsSessionMap;
${extractFunctionSource(serverSource, 'handleCodexAppSteerMessage')}
return { handleCodexAppSteerMessage };
`)(fs, path, crypto, dir, storeApi, maps, sent, requestCalls);
return { ...api, storeApi, sent, requestCalls, maps };
}
// —— 跨会话回传失败重试 ——
function createCrossConversationApi(dir, options = {}) {
const storeApi = createServerHarness(dir);
const sent = [];
const pending = new Map();
const api = new Function('fs', 'path', 'crypto', 'dir', 'storeApi', 'pending', 'sent', 'options', 'CCWEB_REPLY_MODES', `
const wsSend = (ws, payload) => sent.push(payload);
const loadSession = storeApi.loadSession;
const saveSession = session => (options.failSave ? false : storeApi.saveSession(session));
const isSessionRunning = () => false;
const findViewingSessionWs = () => null;
const broadcastSessionList = () => {};
const startCrossConversationReplyAutoRun = () => false;
const saveCrossConversationReplies = () => {};
const pendingCrossConversationReplies = pending;
${[
'readPositiveIntEnv',
'sanitizeId',
'truncateTextValue',
'normalizeCrossConversationReplyState',
'setPendingCrossConversationReply',
'updatePendingCrossConversationReply',
'deletePendingCrossConversationReply',
'hasProcessedCrossConversationReply',
'extractCrossConversationReplyText',
'buildCrossConversationReplyContent',
'deliverCrossConversationReply',
'flushPendingCrossConversationReplies',
].map(name => extractFunctionSource(serverSource, name)).join('\n')}
${['PERSIST_TRUNCATED_TEXT', 'CROSS_CONVERSATION_MAX_CONTENT_CHARS'].map(name => extractConstSource(serverSource, name)).join('\n')}
return {
setPendingCrossConversationReply, deliverCrossConversationReply,
flushPendingCrossConversationReplies, pendingCrossConversationReplies: pending,
};
`)(fs, path, crypto, dir, storeApi, pending, sent, options, CCWEB_REPLY_MODES);
return { ...api, storeApi, sent, options };
}
// —— 子会话冲刷失败重试 ——
function createChildFlushApi(dir, options = {}) {
const storeApi = createServerHarness(dir);
const pendingFlushes = new Map();
const api = new Function('fs', 'path', 'dir', 'storeApi', 'pendingFlushes', 'options', `
const loadSession = storeApi.loadSession;
const saveSession = session => (options.failSave ? false : storeApi.saveSession(session));
const findViewingSessionWs = () => null;
const findCcwebMcpChildTargetToolInMessages = (messages, spawnToolId) => {
for (const message of messages || []) {
for (const tool of message?.toolCalls || []) {
if (tool?.id === spawnToolId) return tool;
}
}
return null;
};
const mergeCcwebMcpChildIntoTool = (tool, child) => {
if (!tool) return null;
tool.result = child.finalMessage || child.candidateResult || '';
tool.done = true;
return tool;
};
const pendingCcwebMcpChildSessionFlushes = pendingFlushes;
${[
'isFinalCcwebMcpChildStatus',
'snapshotCcwebMcpChildForPersist',
'flushPendingCcwebMcpChildSession',
'updatePersistedCcwebMcpChildTool',
].map(name => extractFunctionSource(serverSource, name)).join('\n')}
${['CODEX_APP_STATE_FLUSH_DELAY_MS', 'CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS'].map(name => extractConstSource(serverSource, name)).join('\n')}
return {
updatePersistedCcwebMcpChildTool, flushPendingCcwebMcpChildSession,
pendingCcwebMcpChildSessionFlushes: pendingFlushes,
};
`)(fs, path, dir, storeApi, pendingFlushes, options);
return { ...api, storeApi, options };
}
// —— 任务看板元数据适配器 ——
function createTaskBoardAdapterApi(dir) {
const storeApi = createServerHarness(dir);
const api = new Function('fs', 'path', 'dir', 'storeApi', `
const loadSession = storeApi.loadSession;
const saveSession = storeApi.saveSession;
${extractFunctionSource(serverSource, 'saveTaskBoardSessionMetadata')}
return { saveTaskBoardSessionMetadata };
`)(fs, path, dir, storeApi);
return { ...api, storeApi };
}
let checks = 0;
const failures = [];
function check(name, callback) {
let dir = null;
try {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-completion-'));
callback(dir);
checks += 1;
console.log(`通过:${name}`);
} catch (error) {
failures.push(`${name}:${error.message}`);
console.error(`失败:${name}:${inspect(error, { depth: 2, maxArrayLength: 8, maxStringLength: 600 })}`);
} finally {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
}
function checkAsync(name, callback) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-completion-'));
return Promise.resolve()
.then(() => callback(dir))
.then(() => {
checks += 1;
console.log(`通过:${name}`);
})
.catch(error => {
failures.push(`${name}:${error.message}`);
console.error(`失败:${name}:${inspect(error, { depth: 2, maxArrayLength: 8, maxStringLength: 600 })}`);
})
.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
function runTests() {
check('CLI 完成时会话读取失败保留真实输出且读取恢复后恰好提交一次', dir => {
const sessionId = 'cli-complete-load-failure';
const seedApi = createServerHarness(dir);
assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codex', [
{ id: 'user-before-cli', role: 'user', content: '读取异常前已经保存的问题' },
])), true);
const archivePath = path.join(dir, '_history', `${sessionId}.json`);
const archiveBefore = fs.readFileSync(archivePath, 'utf8');
const fullText = 'CLI 完成时必须保留的唯一输出';
const runPath = writeRunArtifacts(dir, sessionId, {
runKey: `cli:${sessionId}:load-failure`,
outputLines: [{ type: 'item.completed', item: { id: 'cli-load-message', type: 'agent_message', text: fullText } }],
});
const outputPath = path.join(runPath, 'output.jsonl');
const outputBefore = fs.readFileSync(outputPath, 'utf8');
// 用真实损坏档案制造“会话暂不可读”:读取被收敛为 null,且不使用跨实例桩函数,
// 修复档案后由同一 storeApi 承担恢复补交,可信来源身份不会丢失。
const corruptedArchive = '{损坏的会话归档:CLI 读取失败夹具';
fs.writeFileSync(archivePath, corruptedArchive);
const api = createCliCompletionApi(dir);
api.activeProcesses.set(sessionId, entryFixture({
ws: {}, fullText, runKey: `cli:${sessionId}:load-failure`,
}));
api.handleProcessComplete(sessionId, 0, null);
assert.equal(fs.existsSync(runPath), true, '读取失败不得清理真实 CLI 运行目录');
assert.equal(fs.readFileSync(outputPath, 'utf8'), outputBefore, '读取失败不得删除或改写唯一 output.jsonl');
assert.equal(fs.readFileSync(archivePath, 'utf8'), corruptedArchive, '读取失败不得重写完整会话归档');
assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_load_failed'), '读取失败应回报 session_load_failed');
assert(api.sent.some(payload => payload.type === 'done' && payload.historySaved === false), '读取失败不得宣称 historySaved=true');
assert.equal(api.activeProcesses.has(sessionId), false, '已经完成的 CLI 不应仍显示运行');
assert.equal(seedApi.loadSession(sessionId), null, '损坏档案下会话读取应被收敛为 null');
fs.writeFileSync(archivePath, archiveBefore);
api.recoverProcesses();
const restored = createServerHarness(dir).loadSession(sessionId);
assert.equal(restored.messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '读取恢复后应恰好补一条 CLI 回复');
assert.equal(restored.messages[0].id, 'user-before-cli', '恢复必须保留先前已经保存的消息');
assert.equal(fs.existsSync(runPath), false, '恢复提交成功后才可清理运行目录');
api.recoverProcesses();
assert.equal(createServerHarness(dir).loadSession(sessionId).messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '再次恢复不得重复提交');
});
check('Codex App 完成时会话读取失败保留完整 turn 且读取恢复后恰好提交一次', dir => {
const sessionId = 'app-complete-load-failure';
const seedApi = createServerHarness(dir);
assert.equal(seedApi.saveSession(sessionFixture(sessionId, 'codexapp', [
{ id: 'user-before-app', role: 'user', content: '读取异常前已经保存的 App 问题' },
])), true);
const archivePath = path.join(dir, '_history', `${sessionId}.json`);
const archiveBefore = fs.readFileSync(archivePath, 'utf8');
// 同 CLI 夹具:损坏真实档案制造“会话暂不可读”,恢复时先修复档案再补交。
const corruptedArchive = '{损坏的会话归档:Codex App 读取失败夹具';
fs.writeFileSync(archivePath, corruptedArchive);
const api = createCodexAppStateApi(dir);
const fullText = 'Codex App 完成时必须保留的唯一输出';
const toolCalls = [{ id: 'app-load-tool', name: 'exec', input: { command: 'fixture command' }, result: '完整工具结果', done: true }];
const entry = {
agent: 'codexapp', ws: {}, cwd: dir, threadId: 'thread-complete-load', turnId: 'turn-complete-load',
turnStatus: 'running', startedAt: new Date(1800000000000).toISOString(), fullText, toolCalls,
toolOutputDeltas: new Map([['app-load-tool', '完整工具结果']]), agentMessageItems: new Map([['app-load-message', fullText]]),
lastUsage: null, lastError: null, userAborted: false, retryRequest: null,
};
api.writeCodexAppTurnState(sessionId, entry);
// 独立建立完整 turn 夹具,使 HEAD 对照也能从真实文件清理行为失败,
// 而不是仅因旧 writer 未实现完整归档或返回值不同而失败。
seedApi.sessionHistoryStore.saveTurn(sessionId, {
version: 1, agent: 'codexapp', sessionId, cwd: dir, threadId: entry.threadId, turnId: entry.turnId,
turnStatus: 'running', startedAt: entry.startedAt, updatedAt: entry.startedAt, fullText, toolCalls,
toolOutputDeltas: [...entry.toolOutputDeltas], agentMessageItems: [...entry.agentMessageItems],
lastUsage: null, lastError: null, userAborted: false,
});
const turnPath = path.join(dir, '_history', '_turns', `${sessionId}.json`);
const runPath = path.join(dir, `${sessionId}-run`);
const previewPath = path.join(runPath, 'codexapp-state.json');
const outputPath = path.join(runPath, 'output.jsonl');
fs.writeFileSync(outputPath, `${JSON.stringify({ type: 'fixture', text: fullText })}\n`);
const outputBefore = fs.readFileSync(outputPath, 'utf8');
assert.equal(fs.existsSync(turnPath), true, '夹具必须包含真实完整 turn 文件');
assert.equal(fs.existsSync(previewPath), true, '夹具必须包含真实运行态预览文件');
api.activeCodexAppTurns.set(sessionId, entry);
api.handleCodexAppTurnComplete(sessionId);
assert.equal(fs.existsSync(turnPath), true, '读取失败不得清理真实完整 turn 文件');
assert.equal(fs.existsSync(runPath), true, '读取失败不得清理 App 运行目录');
assert.equal(fs.existsSync(previewPath), true, '读取失败不得清理运行态预览');
assert.equal(fs.readFileSync(outputPath, 'utf8'), outputBefore, '读取失败不得删除或改写唯一输出文件');
assert.equal(fs.readFileSync(archivePath, 'utf8'), corruptedArchive, '读取失败不得重写完整会话归档');
const retainedTurn = seedApi.sessionHistoryStore.loadTurn(sessionId);
assert.equal(retainedTurn.fullText, fullText, '完成时最新完整正文应仍在运行态中');
assert.deepEqual(retainedTurn.toolCalls, toolCalls);
assert.equal(retainedTurn.turnStatus, 'completed', '完成但归档失败的 turn 必须保留完成状态');
assert.equal(entry.historySaved, false, '会话不可读时 entry 不得宣称已保存');
assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_load_failed'), '读取失败应回报 session_load_failed');
assert(api.sent.some(payload => payload.type === 'done' && payload.historySaved === false), '读取失败的 done 应为 historySaved=false');
assert.equal(api.activeCodexAppTurns.has(sessionId), false, '已经完成的 App turn 不应仍显示运行');
fs.writeFileSync(archivePath, archiveBefore);
assert.equal(api.recoverCodexAppTurnState(sessionId), true);
const restored = createServerHarness(dir).loadSession(sessionId);
assert.equal(restored.messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '读取恢复后应恰好补一条 App 回复');
assert.equal(restored.messages[0].id, 'user-before-app');
assert.deepEqual(restored.messages.find(message => message.role === 'assistant').toolCalls, toolCalls);
assert.equal(restored.messages.some(message => message.codexAppTurnKey?.endsWith(':notice')), false, '完成 turn 恢复不应伪装成被中断');
assert.equal(fs.existsSync(turnPath), false, '归档提交成功后才可删除完整 turn');
assert.equal(fs.existsSync(runPath), false, '归档提交成功后才可清理 App 运行目录');
api.recoverCodexAppTurnState(sessionId);
assert.equal(createServerHarness(dir).loadSession(sessionId).messages.filter(message => message.role === 'assistant' && message.content === fullText).length, 1, '再次恢复不得重复提交 App 回复');
});
check('CLI 纯文本完成回合保存为字符串形状并可重开读取', dir => {
const api = createCliCompletionApi(dir);
const sessionId = 'cli-text';
api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [{ id: 'u1', role: 'user', content: '问题' }]));
writeRunArtifacts(dir, sessionId, { runKey: `cli:${sessionId}:11111111-1111-1111-1111-111111111111` });
api.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '纯文本最终回复', pid: 999999 }));
api.handleProcessComplete(sessionId, 0, null);
const stored = api.storeApi.loadSession(sessionId);
const assistant = stored.messages.at(-1);
assert.equal(assistant.role, 'assistant');
assert.equal(typeof assistant.content, 'string', '纯文本回合必须保持旧字符串形状,不能再变成内容块数组');
assert.equal(assistant.content, '纯文本最终回复');
assert.equal(assistant.runKey, `cli:${sessionId}:11111111-1111-1111-1111-111111111111`);
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '保存成功后应清理运行目录');
assert.equal(api.activeProcesses.has(sessionId), false);
});
check('CLI 混合内容块与纯工具回合完整归档', dir => {
const api = createCliCompletionApi(dir);
const mixedId = 'cli-mixed';
api.storeApi.saveSession(sessionFixture(mixedId, 'codex', []));
const blocks = [
{ type: 'text', text: '前缀正文' },
{ type: 'image', attachmentId: 'att-1', mime: 'image/png' },
{ type: 'text', text: '结构化尾部' },
];
api.activeProcesses.set(mixedId, entryFixture({
ws: {},
fullText: '前缀正文结构化尾部',
contentBlocks: blocks,
toolCalls: [{ id: 'tool-1', name: 'exec', input: { command: 'ls' }, result: 'ok', done: true }],
}));
writeRunArtifacts(dir, mixedId, {});
api.handleProcessComplete(mixedId, 0, null);
const storedMixed = api.storeApi.loadSession(mixedId).messages.at(-1);
assert.deepEqual(storedMixed.content, blocks, '结构化与图片内容块必须原样归档');
assert.equal(storedMixed.toolCalls.length, 1);
const toolOnlyId = 'cli-tool-only';
api.storeApi.saveSession(sessionFixture(toolOnlyId, 'codex', []));
api.activeProcesses.set(toolOnlyId, entryFixture({
ws: {},
fullText: '',
toolCalls: Array.from({ length: 130 }, (_, index) => ({ id: `tool-${index}`, name: 'exec', input: `输入${index}`, result: `结果${index}`, done: true })),
}));
writeRunArtifacts(dir, toolOnlyId, {});
api.handleProcessComplete(toolOnlyId, 0, null);
const storedToolOnly = api.storeApi.loadSession(toolOnlyId).messages.at(-1);
assert.equal(storedToolOnly.content, '', '纯工具回合正文保持空字符串');
assert.equal(storedToolOnly.toolCalls.length, 130, '纯工具回合的 130 条工具调用必须完整归档');
});
check('CLI 完成保存失败时保留运行目录与唯一输出', dir => {
const api = createCliCompletionApi(dir);
const sessionId = 'cli-save-failure';
api.storeApi.saveSession(sessionFixture(sessionId, 'codex', []));
const runPath = writeRunArtifacts(dir, sessionId, {
outputLines: [{ type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: '尚未落盘的唯一正文' } }],
});
api.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '尚未落盘的唯一正文' }));
const failingApi = createCliCompletionApi(dir, { saveSession: () => false, fs: fs, storeFs: fs });
failingApi.activeProcesses.set(sessionId, entryFixture({ ws: {}, fullText: '尚未落盘的唯一正文' }));
failingApi.handleProcessComplete(sessionId, 0, null);
assert.equal(fs.existsSync(runPath), true, '保存失败不得清理运行目录');
assert.equal(fs.existsSync(path.join(runPath, 'output.jsonl')), true, '原始输出必须保留');
assert(api.storeApi.loadSession(sessionId).messages.length === 0, '保存失败时不能假装已入库');
assert(failingApi.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应上报保存失败');
assert(failingApi.sent.some(payload => payload.type === 'done' && payload.historySaved === false), 'done 应标记 historySaved=false');
// 存储恢复后由 recoverProcesses 补齐,且重复恢复不重复入库。
api.recoverProcesses();
const recovered = api.storeApi.loadSession(sessionId);
assert.equal(recovered.messages.length, 1, '恢复后应恰好补入一条助手消息');
assert.equal(recovered.messages[0].content, '尚未落盘的唯一正文');
assert.equal(fs.existsSync(runPath), false, '成功补齐后应清理运行目录');
api.recoverProcesses();
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '重复恢复不得重复入库');
});
check('CLI 清理失败后的重复恢复保持幂等', dir => {
const failingFs = new Proxy(fs, {
get(target, key) {
if (key === 'rmSync') return (targetPath, options) => {
if (String(targetPath).endsWith('-run')) {
const error = new Error('模拟清理失败');
error.code = 'EACCES';
throw error;
}
return target.rmSync(targetPath, options);
};
return target[key];
},
});
const api = createCliCompletionApi(dir, { fs: failingFs });
const sessionId = 'cli-cleanup-failure';
api.storeApi.saveSession(sessionFixture(sessionId, 'codex', []));
const runPath = writeRunArtifacts(dir, sessionId, {
outputLines: [{ type: 'item.completed', item: { id: 'm1', type: 'agent_message', text: '清理失败也要只存一次' } }],
});
api.recoverProcesses();
assert.equal(fs.existsSync(runPath), true, '清理失败时运行目录仍在');
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '首次恢复应入库一次');
api.recoverProcesses();
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '第二次恢复必须按 runKey 去重');
fs.rmSync(runPath, { recursive: true, force: true });
api.recoverProcesses();
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 1, '清理完成后仍不得重复入库');
});
check('Codex App 完整 turn 运行态在完成前保存且恢复只入库一次', dir => {
const api = createCodexAppStateApi(dir);
const sessionId = 'codexapp-full';
api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [{ id: 'u1', role: 'user', content: '长任务' }]));
const fullText = '长正文'.repeat(150000) + '[尾部标记]';
const toolCalls = [{ id: 'tool-1', name: 'exec', input: { command: 'ls' }, result: 'ok', done: true }];
const entry = {
agent: 'codexapp', cwd: dir, threadId: 'thread-1', turnId: 'turn-1', turnStatus: 'running',
startedAt: new Date(1800000000000).toISOString(), fullText, toolCalls,
toolOutputDeltas: new Map([['tool-1', 'delta']]), agentMessageItems: new Map([['m1', 'item']]),
lastUsage: { inputTokens: 1 }, lastError: null, userAborted: false,
};
assert.equal(api.writeCodexAppTurnState(sessionId, entry), true, '完整 turn 状态应保存成功');
const completeTurn = api.storeApi.sessionHistoryStore.loadTurn(sessionId);
assert.equal(completeTurn.fullText, fullText, '完整运行态不得被预览上限裁剪');
assert.deepEqual(completeTurn.toolCalls, toolCalls);
assert.equal(api.loadCodexAppTurnState(sessionId).fullText, fullText, '恢复必须优先读取完整运行态');
assert.equal(api.recoverCodexAppTurnState(sessionId), true);
const session = api.storeApi.loadSession(sessionId);
const assistant = session.messages.find(message => message.role === 'assistant');
assert(assistant, '恢复后应有助手消息');
assert.equal(assistant.content, fullText);
assert.equal(assistant.toolCalls.length, 1);
assert.equal(session.messages.some(message => message.role === 'system'), true, '未完成 turn 应有中断提示');
assert.equal(api.storeApi.sessionHistoryStore.loadTurn(sessionId), null, '恢复后应清理完整运行态');
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '恢复后应清理运行目录');
api.recoverCodexAppTurnState(sessionId);
assert.equal(api.storeApi.loadSession(sessionId).messages.filter(message => message.role === 'assistant').length, 1, '重复恢复不得重复入库');
});
check('Codex App 会话暂不可读时保留运行态并在恢复后入库', dir => {
const api = createCodexAppStateApi(dir, { loadSession: () => null });
const sessionId = 'codexapp-unavailable';
api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', []));
const writeApi = createCodexAppStateApi(dir);
const entry = {
agent: 'codexapp', cwd: dir, threadId: 'thread-2', turnId: 'turn-2', turnStatus: 'completed',
startedAt: new Date(1800000000000).toISOString(), fullText: '会话暂不可读时的输出', toolCalls: [],
toolOutputDeltas: new Map(), agentMessageItems: new Map(), lastUsage: null, lastError: null, userAborted: false,
};
writeApi.writeCodexAppTurnState(sessionId, entry);
assert.equal(api.recoverCodexAppTurnState(sessionId), true, '会话不可读时应保留运行态');
assert(api.storeApi.sessionHistoryStore.loadTurn(sessionId), '完整运行态不得被清理');
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), true, '运行目录不得被清理');
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`, 'codexapp-state.json')), true, '预览状态不得被清理');
const recoveryApi = createCodexAppStateApi(dir);
assert.equal(recoveryApi.recoverCodexAppTurnState(sessionId), true);
const session = recoveryApi.storeApi.loadSession(sessionId);
assert.equal(session.messages.filter(message => message.role === 'assistant').length, 1, '会话恢复后应恰好入库一次');
assert.equal(session.messages.find(message => message.role === 'assistant').content, '会话暂不可读时的输出');
assert.equal(recoveryApi.storeApi.sessionHistoryStore.loadTurn(sessionId), null);
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false);
});
check('Codex App 损坏运行态文件保留且不误入库', dir => {
const api = createCodexAppStateApi(dir);
const sessionId = 'codexapp-corrupt';
api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', []));
const runPath = path.join(dir, `${sessionId}-run`);
fs.mkdirSync(runPath, { recursive: true });
fs.writeFileSync(path.join(runPath, 'codexapp-state.json'), '{ 损坏的 JSON');
assert.equal(api.recoverCodexAppTurnState(sessionId), true);
assert.equal(fs.existsSync(path.join(runPath, 'codexapp-state.json')), true, '损坏状态文件必须保留供人工排查');
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '损坏状态不得伪造助手消息');
});
check('Codex App 完成保存失败时先保留完整运行态再可恢复', dir => {
const api = createCodexAppStateApi(dir, { saveSession: () => false });
const sessionId = 'codexapp-save-failure';
api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', [{ id: 'u1', role: 'user', content: '任务' }]));
api.activeCodexAppTurns.set(sessionId, {
agent: 'codexapp', ws: {}, threadId: 'thread-3', turnId: 'turn-3', turnStatus: 'running',
startedAt: new Date(1800000000000).toISOString(), fullText: '完成时尚未入库的唯一正文', toolCalls: [],
toolOutputDeltas: new Map(), agentMessageItems: new Map(), lastUsage: null, lastError: null, userAborted: false,
retryRequest: null,
});
api.handleCodexAppTurnComplete(sessionId);
assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应上报完整会话保存失败');
const savedTurn = api.storeApi.sessionHistoryStore.loadTurn(sessionId);
assert(savedTurn, '保存失败时必须留下完整运行态');
assert.equal(savedTurn.fullText, '完成时尚未入库的唯一正文');
assert.equal(savedTurn.turnStatus, 'completed');
assert.equal(api.activeCodexAppTurns.has(sessionId), false);
const recoveryApi = createCodexAppStateApi(dir);
assert.equal(recoveryApi.recoverCodexAppTurnState(sessionId), true);
const session = recoveryApi.storeApi.loadSession(sessionId);
assert.equal(session.messages.filter(message => message.role === 'assistant' && message.content === '完成时尚未入库的唯一正文').length, 1, '恢复后应恰好一条正文消息');
assert.equal(session.messages.some(message => message.role === 'system'), false, '已完成 turn 恢复不应补中断提示');
});
check('初次用户消息保存失败时不启动运行且不产生运行目录', dir => {
const api = createMessageSaveFailureApi(dir, { saveSession: () => false });
const sessionId = 'message-save-failure';
api.storeApi.saveSession(sessionFixture(sessionId, 'codex', []));
const result = api.handleMessage({ readyState: 1 }, { text: '新问题', sessionId });
assert.equal(result?.ok, false);
assert.equal(result?.code, 'session_save_failed');
assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'), '应回报用户消息保存失败');
assert.equal(fs.existsSync(path.join(dir, `${sessionId}-run`)), false, '保存失败不得创建运行目录');
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '磁盘上的会话不得被静默改写');
});
check('跨会话回传保存失败保留 ready 且重试恰好一个气泡', dir => {
const api = createCrossConversationApi(dir);
const sourceId = 'src';
const targetId = 'tgt';
api.storeApi.saveSession(sessionFixture(sourceId, 'codex', []));
api.storeApi.saveSession(sessionFixture(targetId, 'codex', [{ id: 'u1', role: 'user', content: '请求' }]));
const pendingFixture = () => ({
requestId: 'req-1', messageId: 'm1', sourceConversationId: sourceId, sourceTitle: '来源',
targetConversationId: targetId, targetTitle: '目标', status: 'ready',
createdAt: new Date().toISOString(), hopCount: 1, sourceAutoRun: false,
originalRequest: '原始请求', replyText: '子对话返回正文', completedAt: null, returnedAt: null,
});
assert(api.setPendingCrossConversationReply('req-1', pendingFixture()));
api.options.failSave = true;
assert.equal(api.deliverCrossConversationReply('req-1'), false, '保存失败时回传不得计为完成');
const retained = api.pendingCrossConversationReplies.get('req-1');
assert.equal(retained.status, 'ready', '保存失败后必须退回 ready 以保留重试机会');
assert.equal(retained.lastError, 'session_save_failed');
assert.equal(api.storeApi.loadSession(sourceId).messages.length, 0, '保存失败不得留下半条回传气泡');
api.options.failSave = false;
assert.equal(api.deliverCrossConversationReply('req-1'), true);
assert.equal(api.pendingCrossConversationReplies.has('req-1'), false);
const delivered = api.storeApi.loadSession(sourceId).messages;
assert.equal(delivered.filter(message => message.crossConversation?.replyToRequestId === 'req-1').length, 1);
assert(delivered[0].content.includes('子对话返回正文'));
// 模拟“气泡已落盘但等待状态未更新”的崩溃重试:处理标记必须阻止第二个气泡。
api.setPendingCrossConversationReply('req-1', pendingFixture());
assert.equal(api.deliverCrossConversationReply('req-1'), true);
api.flushPendingCrossConversationReplies(sourceId);
assert.equal(
api.storeApi.loadSession(sourceId).messages.filter(message => message.crossConversation?.replyToRequestId === 'req-1').length,
1,
'崩溃重试不得重复回传气泡',
);
});
check('子会话冲刷保存失败时保留队列并在恢复后提交', dir => {
const api = createChildFlushApi(dir, { failSave: true });
const sessionId = 'child-parent';
api.storeApi.saveSession(sessionFixture(sessionId, 'codex', [{
id: 'm1', role: 'assistant', content: '',
toolCalls: [{ id: 'spawn-1', name: 'spawn_agent', input: {}, done: false }],
}]));
const tool = api.updatePersistedCcwebMcpChildTool(sessionId, {
threadId: 'th-1', spawnToolId: 'spawn-1', status: 'running', finalMessage: '子代理结果',
});
assert(tool, '子会话状态应能合并到目标工具');
assert(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), '状态更新后应存在待冲刷队列');
assert.equal(api.storeApi.loadSession(sessionId).messages[0].toolCalls[0].result, undefined, '保存失败不得假装已落盘');
assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), false, '保存失败时冲刷必须返回 false');
assert(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), '保存失败后队列必须保留');
const pending = api.pendingCcwebMcpChildSessionFlushes.get(sessionId);
assert.equal(pending.retryScheduled, true, '保存失败后应安排一次有界重试');
if (pending.timer) {
clearTimeout(pending.timer);
pending.timer = null;
}
api.options.failSave = false;
assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), true, '存储恢复后冲刷应成功');
assert.equal(api.pendingCcwebMcpChildSessionFlushes.has(sessionId), false);
assert.equal(api.storeApi.loadSession(sessionId).messages[0].toolCalls[0].result, '子代理结果');
assert.equal(api.flushPendingCcwebMcpChildSession(sessionId), false, '队列已清空后再次冲刷应为空操作');
});
check('任务看板元数据适配器保留完整历史且不接受截断对象', dir => {
const api = createTaskBoardAdapterApi(dir);
const sessionId = 'task-adapter';
const messages = Array.from({ length: 650 }, (_, index) => ({
id: `m-${index}`,
role: index % 2 ? 'assistant' : 'user',
content: `完整历史 ${index}`,
timestamp: new Date(1800000000000 + index * 1000).toISOString(),
}));
api.storeApi.saveSession({
...sessionFixture(sessionId, 'codexapp', messages),
taskTracking: { enabled: true, statusId: 'unassigned', version: 1 },
});
const preview = JSON.parse(fs.readFileSync(path.join(dir, `${sessionId}.json`), 'utf8'));
assert(preview.messages.length <= 181, '磁盘快照本身应有界');
// 任务服务只回传它负责的元数据,适配器必须重新读取可信档案后提交。
assert.equal(api.saveTaskBoardSessionMetadata({
id: sessionId,
taskTracking: { enabled: true, statusId: 'waiting-release', version: 2 },
}), true);
const reloaded = createServerHarness(dir).loadSession(sessionId);
assert.equal(reloaded.messages.length, 650, '任务看板保存元数据不得截断完整历史');
assert.equal(reloaded.messages[0].content, '完整历史 0');
assert.equal(reloaded.taskTracking.statusId, 'waiting-release');
assert.equal(reloaded.taskTracking.version, 2);
});
return checkAsync('Codex App steer 保存失败时不发送 turn/steer', async dir => {
const api = createSteerSaveFailureApi(dir);
const sessionId = 'steer-save-failure';
api.storeApi.saveSession(sessionFixture(sessionId, 'codexapp', []));
api.maps.activeCodexAppTurns.set(sessionId, { ws: null, threadId: 'thread-steer', turnId: 'turn-steer' });
api.handleCodexAppSteerMessage({ readyState: 1 }, { text: '引导内容', sessionId, clientMessageId: 'c1' });
await new Promise(resolve => setTimeout(resolve, 25));
assert.equal(api.requestCalls.length, 0, '保存失败不得调用 turn/steer');
assert(api.sent.some(payload => payload.type === 'codex_app_steer_status' && payload.status === 'failed'));
assert(api.sent.some(payload => payload.type === 'error' && payload.code === 'session_save_failed'));
assert.equal(api.storeApi.loadSession(sessionId).messages.length, 0, '保存失败的引导消息不得出现在磁盘');
});
}
if (require.main === module) {
const baselineHead = process.argv.includes('--baseline-head');
if (baselineHead) {
serverSource = execFileSync('git', ['show', 'HEAD:server.js'], { cwd: REPO_DIR, encoding: 'utf8' });
process.on('unhandledRejection', error => {
failures.push(`基线异步失败:${error?.message || error}`);
});
}
runTests().then(() => {
if (baselineHead) {
const expectedFailures = [
'CLI 完成时会话读取失败保留真实输出且读取恢复后恰好提交一次',
'Codex App 完成时会话读取失败保留完整 turn 且读取恢复后恰好提交一次',
'CLI 纯文本完成回合保存为字符串形状并可重开读取',
'CLI 完成保存失败时保留运行目录与唯一输出',
'Codex App 完整 turn 运行态在完成前保存且恢复只入库一次',
'Codex App 会话暂不可读时保留运行态并在恢复后入库',
'初次用户消息保存失败时不启动运行且不产生运行目录',
'跨会话回传保存失败保留 ready 且重试恰好一个气泡',
'子会话冲刷保存失败时保留队列并在恢复后提交',
'任务看板元数据适配器保留完整历史且不接受截断对象',
];
const failingNames = failures.map(failure => failure.split(':')[0]);
const missing = expectedFailures.filter(name => !failingNames.includes(name));
if (missing.length > 0) {
console.error(`\n基线复现缺少预期失败项:${missing.join('、')}`);
process.exit(1);
}
console.log(`\n基线对照(HEAD):${failures.length} 项按预期失败,覆盖旧实现的丢失/覆盖/重复缺陷`);
process.exit(0);
}
if (failures.length > 0) {
console.error(`\n${failures.length}/${checks + failures.length} 项失败`);
process.exit(1);
}
console.log(`\n${checks} 项全部通过`);
});
}
module.exports = { createCliCompletionApi, createCodexAppStateApi };