1010 lines
51 KiB
JavaScript
1010 lines
51 KiB
JavaScript
#!/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 };
|