修复完整会话历史与长气泡交互并更新 CentOS 7 发布包
提交当前工作区全部源码、回归脚本、任务记录及重新构建的 Bun baseline 发布包。发布单文件 MCP 初始化及前端资源一致性验证通过。
This commit is contained in:
@@ -15,7 +15,6 @@ function createAgentRuntime(deps) {
|
||||
internalMcpToken,
|
||||
nodePath,
|
||||
wsSend,
|
||||
truncateObj,
|
||||
sanitizeToolInput,
|
||||
loadSession,
|
||||
saveSession,
|
||||
@@ -32,11 +31,6 @@ function createAgentRuntime(deps) {
|
||||
return Math.max(min, Math.min(max, raw));
|
||||
}
|
||||
|
||||
const RUNTIME_FULL_TEXT_MAX_CHARS = readRuntimePositiveIntEnv(
|
||||
'CC_WEB_RUNTIME_FULL_TEXT_MAX_CHARS',
|
||||
256 * 1024,
|
||||
{ min: 4096 },
|
||||
);
|
||||
const CCWEB_MCP_STARTUP_TIMEOUT_SEC = readRuntimePositiveIntEnv(
|
||||
'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC',
|
||||
30,
|
||||
@@ -47,17 +41,8 @@ function createAgentRuntime(deps) {
|
||||
60,
|
||||
{ min: 10, max: 600 },
|
||||
);
|
||||
const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n';
|
||||
|
||||
function keepTail(value, maxLen) {
|
||||
const text = String(value || '');
|
||||
if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text;
|
||||
const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length);
|
||||
return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`;
|
||||
}
|
||||
|
||||
function appendCappedText(current, addition, maxLen = RUNTIME_FULL_TEXT_MAX_CHARS) {
|
||||
return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen);
|
||||
function appendFullText(current, addition) {
|
||||
return `${String(current || '')}${String(addition || '')}`;
|
||||
}
|
||||
|
||||
function tomlString(value) {
|
||||
@@ -282,7 +267,7 @@ function createAgentRuntime(deps) {
|
||||
function codexToolInput(item) {
|
||||
if (!item) return null;
|
||||
if (item.type === 'command_execution') return { command: item.command || '' };
|
||||
return truncateObj(item, 500);
|
||||
return item;
|
||||
}
|
||||
|
||||
function codexToolMeta(item) {
|
||||
@@ -331,7 +316,7 @@ function createAgentRuntime(deps) {
|
||||
if (!item) return '';
|
||||
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
|
||||
if (typeof item.text === 'string' && item.text) return item.text;
|
||||
return JSON.stringify(truncateObj(item, 1200));
|
||||
return JSON.stringify(item);
|
||||
}
|
||||
|
||||
function formatAgentMessageDividerTime(date = new Date()) {
|
||||
@@ -369,7 +354,7 @@ function createAgentRuntime(deps) {
|
||||
? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
|
||||
: '';
|
||||
const chunk = separator + nextText;
|
||||
entry.fullText = appendCappedText(entry.fullText || '', chunk);
|
||||
entry.fullText = appendFullText(entry.fullText || '', chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
@@ -405,6 +390,38 @@ function createAgentRuntime(deps) {
|
||||
function processClaudeEvent(entry, event, sessionId) {
|
||||
if (!event || !event.type) return;
|
||||
|
||||
function toolResultText(block) {
|
||||
if (typeof block?.content === 'string') return block.content;
|
||||
if (Array.isArray(block?.content)) {
|
||||
return block.content.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (typeof part?.text === 'string') return part.text;
|
||||
return JSON.stringify(part);
|
||||
}).filter(Boolean).join('\n');
|
||||
}
|
||||
if (block?.content === undefined) return '';
|
||||
return JSON.stringify(block.content);
|
||||
}
|
||||
|
||||
function processToolResult(block) {
|
||||
const toolId = String(block?.tool_use_id || block?.toolUseId || '').trim();
|
||||
if (!toolId) return;
|
||||
const resultText = toolResultText(block);
|
||||
let tc = entry.toolCalls.find((tool) => tool.id === toolId);
|
||||
if (!tc) {
|
||||
tc = { name: block.name || 'ClaudeTool', id: toolId, input: null, done: false };
|
||||
entry.toolCalls.push(tc);
|
||||
}
|
||||
tc.done = true;
|
||||
tc.result = resultText;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_end',
|
||||
toolUseId: toolId,
|
||||
input: tc.input,
|
||||
result: resultText,
|
||||
});
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'system':
|
||||
if (event.session_id) {
|
||||
@@ -422,26 +439,14 @@ function createAgentRuntime(deps) {
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
entry.fullText = appendCappedText(entry.fullText || '', block.text);
|
||||
entry.fullText = appendFullText(entry.fullText || '', block.text);
|
||||
sendRuntime(entry, sessionId, { type: 'text_delta', text: block.text });
|
||||
} else if (block.type === 'tool_use') {
|
||||
const toolInput = sanitizeToolInput(block.name, block.input);
|
||||
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
|
||||
entry.toolCalls.push(tc);
|
||||
sendRuntime(entry, sessionId, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
|
||||
} else if (block.type === 'tool_result') {
|
||||
const resultText = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.map((c) => c.text || '').join('\n')
|
||||
: JSON.stringify(block.content);
|
||||
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
|
||||
if (tc) {
|
||||
tc.done = true;
|
||||
tc.result = resultText.slice(0, 2000);
|
||||
}
|
||||
sendRuntime(entry, sessionId, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
|
||||
}
|
||||
} else if (block.type === 'tool_result') processToolResult(block);
|
||||
}
|
||||
|
||||
if (event.session_id) {
|
||||
@@ -454,6 +459,15 @@ function createAgentRuntime(deps) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'user': {
|
||||
const content = event.message?.content;
|
||||
if (!Array.isArray(content)) break;
|
||||
for (const block of content) {
|
||||
if (block?.type === 'tool_result') processToolResult(block);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
@@ -495,7 +509,7 @@ function createAgentRuntime(deps) {
|
||||
const item = event.item;
|
||||
if (!item || !item.id || item.type === 'agent_message') break;
|
||||
const tc = ensureCodexToolCall(entry, item, sessionId);
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
const resultText = codexToolResult(item);
|
||||
tc.done = false;
|
||||
tc.result = resultText;
|
||||
|
||||
@@ -522,12 +536,20 @@ function createAgentRuntime(deps) {
|
||||
} catch {}
|
||||
|
||||
if (parsedContent && Array.isArray(parsedContent)) {
|
||||
if (!entry.contentBlocks) entry.contentBlocks = [];
|
||||
// 只有出现结构化内容时才切换到内容块形状。纯文本回合继续使用
|
||||
// 旧的字符串存储格式,避免下游把数组误转成 `[object Object]`。
|
||||
// 如果前面已经收到普通文本,需要先把它作为首个文本块补回,
|
||||
// 这样混合回合仍能保留完整的顺序和正文。
|
||||
if (!entry.contentBlocks) {
|
||||
entry.contentBlocks = [];
|
||||
if (entry.fullText) entry.contentBlocks.push({ type: 'text', text: entry.fullText });
|
||||
}
|
||||
entry.contentBlocks.push(...parsedContent);
|
||||
const textOnly = parsedContent.filter(b => b.type === 'text').map(b => b.text || '').join('');
|
||||
appendCodexAgentText(entry, textOnly);
|
||||
sendRuntime(entry, sessionId, { type: 'content_blocks', blocks: parsedContent });
|
||||
} else {
|
||||
if (entry.contentBlocks) entry.contentBlocks.push({ type: 'text', text: item.text });
|
||||
const textChunk = appendCodexAgentText(entry, item.text);
|
||||
sendRuntime(entry, sessionId, { type: 'text_delta', text: textChunk });
|
||||
}
|
||||
@@ -535,13 +557,14 @@ function createAgentRuntime(deps) {
|
||||
break;
|
||||
}
|
||||
const tc = ensureCodexToolCall(entry, item, sessionId);
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
const resultText = codexToolResult(item);
|
||||
tc.done = true;
|
||||
tc.result = resultText;
|
||||
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_end',
|
||||
toolUseId: item.id,
|
||||
input: tc.input,
|
||||
result: resultText,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
|
||||
@@ -13,14 +13,9 @@ function readPositiveIntEnv(name, fallback, options = {}) {
|
||||
return Math.max(min, Math.min(max, raw));
|
||||
}
|
||||
|
||||
const RUNTIME_FULL_TEXT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_FULL_TEXT_MAX_CHARS', 256 * 1024, { min: 4096 });
|
||||
const RUNTIME_AGENT_ITEM_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_AGENT_ITEM_MAX_CHARS', 128 * 1024, { min: 4096 });
|
||||
const RUNTIME_TOOL_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_DELTA_MAX_CHARS', 64 * 1024, { min: 1024 });
|
||||
const RUNTIME_TOOL_RESULT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_RESULT_MAX_CHARS', 32 * 1024, { min: 1024 });
|
||||
const RUNTIME_TOOL_INPUT_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_TOOL_INPUT_MAX_CHARS', 16 * 1024, { min: 1024 });
|
||||
const RUNTIME_STREAM_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STREAM_DELTA_MAX_CHARS', 16 * 1024, { min: 1024 });
|
||||
const RUNTIME_MAX_TOOL_CALLS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_MAX_TOOL_CALLS', 120, { min: 1, max: 1000 });
|
||||
const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n';
|
||||
const RUNTIME_TRUNCATED_TAIL = '\n[cc-web: 内容过长,已截断以保护服务稳定性]';
|
||||
const CODEX_APP_PLAN_ITEM_TYPES = new Set(['plan', 'plan_list', 'planlist', 'todo', 'todo_list', 'todolist', 'task_list']);
|
||||
const CODEX_APP_PLAN_TOOL_NAMES = new Set(['update_plan', 'plan', 'plan_list', 'todo_list', 'updateplan', 'todolist']);
|
||||
@@ -36,7 +31,6 @@ function createCodexAppRuntime(deps = {}) {
|
||||
wsSend,
|
||||
loadSession,
|
||||
saveSession,
|
||||
truncateObj,
|
||||
} = deps;
|
||||
|
||||
function limitPreviewValue(value, options = {}, depth = 0, seen = new WeakSet()) {
|
||||
@@ -102,19 +96,13 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return `${text.slice(0, keep)}${RUNTIME_TRUNCATED_TAIL}`;
|
||||
}
|
||||
|
||||
function keepTail(value, maxLen) {
|
||||
const text = String(value || '');
|
||||
if (!Number.isFinite(maxLen) || maxLen <= 0 || text.length <= maxLen) return text;
|
||||
const keep = Math.max(0, maxLen - RUNTIME_TRUNCATED_HEAD.length);
|
||||
return `${RUNTIME_TRUNCATED_HEAD}${text.slice(-keep)}`;
|
||||
function appendFullText(current, addition) {
|
||||
return `${String(current || '')}${String(addition || '')}`;
|
||||
}
|
||||
|
||||
function appendCappedText(current, addition, maxLen) {
|
||||
return keepTail(`${String(current || '')}${String(addition || '')}`, maxLen);
|
||||
}
|
||||
|
||||
function capStreamDelta(text) {
|
||||
return truncateEnd(text, RUNTIME_STREAM_DELTA_MAX_CHARS);
|
||||
function stringifyFullValue(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function truncate(value, maxLen) {
|
||||
@@ -243,7 +231,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
if (!entries) return null;
|
||||
const items = entries
|
||||
.map((entry) => {
|
||||
const text = truncateEnd(planEntryText(entry), RUNTIME_TOOL_INPUT_MAX_CHARS);
|
||||
const text = planEntryText(entry);
|
||||
if (!text) return null;
|
||||
return {
|
||||
text,
|
||||
@@ -265,7 +253,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
const status = normalizeIdentifier(entry.status || entry.state);
|
||||
return ['inprogress', 'in_progress', 'active', 'running', 'working'].includes(status);
|
||||
});
|
||||
return truncateEnd(planEntryText(activeEntry), RUNTIME_TOOL_INPUT_MAX_CHARS);
|
||||
return planEntryText(activeEntry);
|
||||
}
|
||||
|
||||
function isTodoListPlanDone(item, todoList) {
|
||||
@@ -397,13 +385,13 @@ function createCodexAppRuntime(deps = {}) {
|
||||
status,
|
||||
hasReadableSourceTitle: !!title,
|
||||
};
|
||||
if (prompt) state.taskDescription = truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS);
|
||||
if (prompt) state.taskDescription = prompt;
|
||||
return {
|
||||
type: 'subAgentActivity',
|
||||
kind: activityKind,
|
||||
agentThreadId,
|
||||
agentPath,
|
||||
prompt: prompt ? truncateEnd(prompt, RUNTIME_TOOL_INPUT_MAX_CHARS) : null,
|
||||
prompt: prompt || null,
|
||||
tool: 'subAgentActivity',
|
||||
status,
|
||||
receiverThreadIds: agentThreadId ? [agentThreadId] : [],
|
||||
@@ -438,7 +426,25 @@ function createCodexAppRuntime(deps = {}) {
|
||||
}
|
||||
|
||||
function sendRuntime(entry, sessionId, payload) {
|
||||
wsSend(entry.ws, { ...payload, sessionId });
|
||||
const output = { ...payload, sessionId };
|
||||
if (output.type === 'text_delta') {
|
||||
// 分块发送长增量而不是截断,客户端累积正文应与服务端完整正文一致。
|
||||
const text = String(output.text || '');
|
||||
const chunkSize = Math.min(RUNTIME_STREAM_DELTA_MAX_CHARS, 16 * 1024);
|
||||
for (let offset = 0; offset < text.length; offset += chunkSize) {
|
||||
wsSend(entry.ws, { ...output, text: text.slice(offset, offset + chunkSize) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const key of ['text', 'message', 'result', 'input', 'blocks', 'meta']) {
|
||||
if (Object.prototype.hasOwnProperty.call(output, key)) {
|
||||
output[key] = limitPreviewValue(output[key], {
|
||||
maxString: key === 'input' ? RUNTIME_TOOL_INPUT_MAX_CHARS : key === 'text' || key === 'message'
|
||||
? RUNTIME_STREAM_DELTA_MAX_CHARS : RUNTIME_TOOL_RESULT_MAX_CHARS,
|
||||
});
|
||||
}
|
||||
}
|
||||
return wsSend(entry.ws, output);
|
||||
}
|
||||
|
||||
function formatAgentMessageDividerTime(date = new Date()) {
|
||||
@@ -521,53 +527,43 @@ function createCodexAppRuntime(deps = {}) {
|
||||
if (todoList) return todoList;
|
||||
switch (item.type) {
|
||||
case 'commandExecution':
|
||||
return { command: truncateEnd(item.command || '', RUNTIME_TOOL_INPUT_MAX_CHARS) };
|
||||
return { command: item.command || '' };
|
||||
case 'mcpToolCall':
|
||||
return {
|
||||
server: truncateEnd(item.server || '', 256),
|
||||
tool: truncateEnd(item.tool || '', 256),
|
||||
arguments: limitPreviewValue(item.arguments ?? null, {
|
||||
maxString: RUNTIME_TOOL_INPUT_MAX_CHARS,
|
||||
maxDepth: 5,
|
||||
maxArray: 80,
|
||||
maxKeys: 80,
|
||||
}),
|
||||
server: item.server || '',
|
||||
tool: item.tool || '',
|
||||
arguments: item.arguments ?? null,
|
||||
};
|
||||
case 'fileChange':
|
||||
return { changes: limitPreviewValue(item.changes || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }) };
|
||||
return { changes: item.changes || [] };
|
||||
case 'reasoning':
|
||||
return {
|
||||
content: limitPreviewValue(item.content || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }),
|
||||
summary: limitPreviewValue(item.summary || [], { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 4 }),
|
||||
content: item.content || [],
|
||||
summary: item.summary || [],
|
||||
};
|
||||
case 'dynamicToolCall':
|
||||
return {
|
||||
tool: truncateEnd(item.tool || '', 256),
|
||||
namespace: truncateEnd(item.namespace || '', 256) || null,
|
||||
arguments: limitPreviewValue(item.arguments ?? null, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }),
|
||||
tool: item.tool || '',
|
||||
namespace: item.namespace || null,
|
||||
arguments: item.arguments ?? null,
|
||||
};
|
||||
case 'collabAgentToolCall':
|
||||
return {
|
||||
tool: truncateEnd(item.tool || '', 256),
|
||||
prompt: truncateEnd(item.prompt || '', RUNTIME_TOOL_INPUT_MAX_CHARS) || null,
|
||||
receiverThreadIds: limitPreviewValue(item.receiverThreadIds || [], { maxString: 512, maxDepth: 3 }),
|
||||
agentsStates: limitPreviewValue(item.agentsStates || {}, { maxString: RUNTIME_TOOL_INPUT_MAX_CHARS, maxDepth: 5 }),
|
||||
tool: item.tool || '',
|
||||
prompt: item.prompt || null,
|
||||
receiverThreadIds: item.receiverThreadIds || [],
|
||||
agentsStates: item.agentsStates || {},
|
||||
};
|
||||
case 'subAgentActivity':
|
||||
return normalizeSubAgentActivityItem(item);
|
||||
case 'imageGeneration':
|
||||
return {
|
||||
prompt: truncateEnd(item.prompt || item.query || '', RUNTIME_TOOL_INPUT_MAX_CHARS),
|
||||
prompt: item.prompt || item.query || '',
|
||||
size: item.size || null,
|
||||
quality: item.quality || null,
|
||||
};
|
||||
default:
|
||||
return limitPreviewValue(item, {
|
||||
maxString: Math.min(500, RUNTIME_TOOL_INPUT_MAX_CHARS),
|
||||
maxDepth: 4,
|
||||
maxArray: 30,
|
||||
maxKeys: 40,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,14 +643,14 @@ function createCodexAppRuntime(deps = {}) {
|
||||
|
||||
function stringifyMcpResult(result) {
|
||||
if (!result) return '';
|
||||
if (Array.isArray(result.content)) {
|
||||
if (Array.isArray(result.content) && Object.keys(result).every(key => key === 'content')) {
|
||||
const text = result.content.map((part) => {
|
||||
if (typeof part?.text === 'string') return truncateEnd(part.text, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return safeStringifyPreview(part, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
if (typeof part?.text === 'string') return part.text;
|
||||
return stringifyFullValue(part);
|
||||
}).filter(Boolean).join('\n');
|
||||
if (text) return truncateEnd(text, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
if (text) return text;
|
||||
}
|
||||
return safeStringifyPreview(result, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return stringifyFullValue(result);
|
||||
}
|
||||
|
||||
function itemResult(item) {
|
||||
@@ -663,28 +659,28 @@ function createCodexAppRuntime(deps = {}) {
|
||||
if (todoList) return JSON.stringify(todoList, null, 2);
|
||||
switch (item.type) {
|
||||
case 'commandExecution':
|
||||
return truncateEnd(item.aggregatedOutput || '', RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return item.aggregatedOutput || '';
|
||||
case 'mcpToolCall':
|
||||
return item.error?.message || stringifyMcpResult(item.result);
|
||||
case 'fileChange':
|
||||
return safeStringifyPreview(item.changes || [], RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return stringifyFullValue(item.changes || []);
|
||||
case 'reasoning':
|
||||
return truncateEnd(reasoningTextFromItem(item), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return reasoningTextFromItem(item);
|
||||
case 'dynamicToolCall':
|
||||
return safeStringifyPreview({
|
||||
return stringifyFullValue({
|
||||
success: item.success ?? null,
|
||||
contentItems: item.contentItems || null,
|
||||
}, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
});
|
||||
case 'collabAgentToolCall':
|
||||
return safeStringifyPreview({
|
||||
return stringifyFullValue({
|
||||
status: item.status || null,
|
||||
receiverThreadIds: item.receiverThreadIds || [],
|
||||
agentsStates: item.agentsStates || {},
|
||||
}, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
});
|
||||
case 'subAgentActivity':
|
||||
return safeStringifyPreview(normalizeSubAgentActivityItem(item, true), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return stringifyFullValue(normalizeSubAgentActivityItem(item, true));
|
||||
case 'imageGeneration':
|
||||
return safeStringifyPreview({
|
||||
return stringifyFullValue({
|
||||
status: item.status || null,
|
||||
images: Array.isArray(item.images) ? item.images.map((image) => ({
|
||||
path: image.path || image.filePath || null,
|
||||
@@ -692,17 +688,22 @@ function createCodexAppRuntime(deps = {}) {
|
||||
size: image.size || null,
|
||||
})) : null,
|
||||
outputPath: item.outputPath || item.path || null,
|
||||
}, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
});
|
||||
default:
|
||||
if (typeof item.text === 'string') return truncateEnd(item.text, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
return safeStringifyPreview(item, Math.min(1200, RUNTIME_TOOL_RESULT_MAX_CHARS));
|
||||
if (typeof item.text === 'string') return item.text;
|
||||
return stringifyFullValue(item);
|
||||
}
|
||||
}
|
||||
|
||||
function itemIdForEntry(entry, itemId) {
|
||||
if (!itemId) return itemId;
|
||||
return entry?.runtimeItemScope ? `codexapp-item:${entry.runtimeItemScope}:${itemId}` : itemId;
|
||||
}
|
||||
|
||||
function ensureToolCall(entry, item, sessionId) {
|
||||
if (!item?.id) return null;
|
||||
const kind = itemKind(item);
|
||||
const itemId = isCcwebPlanItem(item) ? 'ccweb-update-plan' : item.id;
|
||||
const itemId = itemIdForEntry(entry, isCcwebPlanItem(item) ? 'ccweb-update-plan' : item.id);
|
||||
let toolCall = entry.toolCalls.find((tool) => tool.id === itemId);
|
||||
if (toolCall) {
|
||||
toolCall.name = itemName(item);
|
||||
@@ -716,31 +717,6 @@ function createCodexAppRuntime(deps = {}) {
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
if (entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS) {
|
||||
let overflowTool = entry.toolCalls.find((tool) => tool.id === 'ccweb-toolcalls-overflow');
|
||||
if (!overflowTool) {
|
||||
overflowTool = {
|
||||
name: 'cc-web',
|
||||
id: 'ccweb-toolcalls-overflow',
|
||||
kind: 'system',
|
||||
meta: { kind: 'system', title: 'Tool Calls', subtitle: 'too many tool calls', status: 'inProgress' },
|
||||
input: null,
|
||||
done: false,
|
||||
result: '工具调用数量过多,后续工具调用已折叠显示。',
|
||||
};
|
||||
entry.toolCalls.push(overflowTool);
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: 'tool_start',
|
||||
name: overflowTool.name,
|
||||
toolUseId: overflowTool.id,
|
||||
input: overflowTool.input,
|
||||
kind: overflowTool.kind,
|
||||
meta: overflowTool.meta,
|
||||
});
|
||||
}
|
||||
return overflowTool;
|
||||
}
|
||||
|
||||
toolCall = {
|
||||
name: itemName(item),
|
||||
id: itemId,
|
||||
@@ -762,6 +738,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
}
|
||||
|
||||
function appendAgentText(entry, itemId, text) {
|
||||
itemId = itemIdForEntry(entry, itemId);
|
||||
const nextText = String(text || '');
|
||||
if (!nextText) return '';
|
||||
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
|
||||
@@ -776,35 +753,36 @@ function createCodexAppRuntime(deps = {}) {
|
||||
const separator = agentMessageSeparator(entry, itemId, nextText);
|
||||
const firstText = currentItemText ? nextText : `${pendingPrefix}${nextText}`;
|
||||
const appended = separator + firstText;
|
||||
entry.agentMessageItems.set(itemId, appendCappedText(currentItemText, firstText, RUNTIME_AGENT_ITEM_MAX_CHARS));
|
||||
entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS);
|
||||
return capStreamDelta(appended);
|
||||
entry.agentMessageItems.set(itemId, appendFullText(currentItemText, firstText));
|
||||
entry.fullText = appendFullText(entry.fullText || '', appended);
|
||||
return appended;
|
||||
}
|
||||
|
||||
function appendAgentCompletedText(entry, item) {
|
||||
const itemId = itemIdForEntry(entry, item?.id);
|
||||
const text = String(item?.text || '');
|
||||
if (!text) return '';
|
||||
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
|
||||
const currentItemText = entry.agentMessageItems.get(item.id) || '';
|
||||
const currentItemText = entry.agentMessageItems.get(itemId) || '';
|
||||
if (!entry.agentMessagePendingPrefixes) entry.agentMessagePendingPrefixes = new Map();
|
||||
const pendingPrefix = entry.agentMessagePendingPrefixes.get(item.id) || '';
|
||||
entry.agentMessagePendingPrefixes.delete(item.id);
|
||||
const pendingPrefix = entry.agentMessagePendingPrefixes.get(itemId) || '';
|
||||
entry.agentMessagePendingPrefixes.delete(itemId);
|
||||
if (!currentItemText && !text.trim()) return '';
|
||||
if (currentItemText && text.startsWith(currentItemText)) {
|
||||
const remainder = text.slice(currentItemText.length);
|
||||
entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS));
|
||||
entry.fullText = appendCappedText(entry.fullText || '', remainder, RUNTIME_FULL_TEXT_MAX_CHARS);
|
||||
return capStreamDelta(remainder);
|
||||
entry.agentMessageItems.set(itemId, text);
|
||||
entry.fullText = appendFullText(entry.fullText || '', remainder);
|
||||
return remainder;
|
||||
}
|
||||
if (currentItemText === text) return '';
|
||||
const completedText = !currentItemText && pendingPrefix && !text.startsWith(pendingPrefix)
|
||||
? `${pendingPrefix}${text}`
|
||||
: text;
|
||||
const separator = agentMessageSeparator(entry, item.id, completedText);
|
||||
const separator = agentMessageSeparator(entry, itemId, completedText);
|
||||
const appended = separator + completedText;
|
||||
entry.agentMessageItems.set(item.id, keepTail(completedText, RUNTIME_AGENT_ITEM_MAX_CHARS));
|
||||
entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS);
|
||||
return capStreamDelta(appended);
|
||||
entry.agentMessageItems.set(itemId, completedText);
|
||||
entry.fullText = appendFullText(entry.fullText || '', appended);
|
||||
return appended;
|
||||
}
|
||||
|
||||
function agentMessageSeparator(entry, itemId, nextText) {
|
||||
@@ -817,12 +795,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
|
||||
function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {
|
||||
if (!itemId) return;
|
||||
itemId = itemIdForEntry(entry, itemId);
|
||||
let toolCall = entry.toolCalls.find((tool) => tool.id === itemId);
|
||||
if (!toolCall) {
|
||||
const targetItemId = entry.toolCalls.length >= RUNTIME_MAX_TOOL_CALLS ? 'ccweb-toolcalls-overflow' : itemId;
|
||||
toolCall = entry.toolCalls.find((tool) => tool.id === targetItemId);
|
||||
itemId = targetItemId;
|
||||
}
|
||||
if (!toolCall) {
|
||||
toolCall = {
|
||||
name: patch.name || 'CodexAppItem',
|
||||
@@ -846,22 +820,16 @@ function createCodexAppRuntime(deps = {}) {
|
||||
if (patch.kind) toolCall.kind = patch.kind;
|
||||
if (patch.meta) toolCall.meta = patch.meta;
|
||||
if (patch.input !== undefined) {
|
||||
toolCall.input = limitPreviewValue(patch.input, {
|
||||
maxString: RUNTIME_TOOL_INPUT_MAX_CHARS,
|
||||
maxDepth: 5,
|
||||
maxArray: 80,
|
||||
maxKeys: 80,
|
||||
});
|
||||
toolCall.input = patch.input;
|
||||
}
|
||||
const safeResult = truncateEnd(result, RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
toolCall.done = done;
|
||||
toolCall.result = safeResult;
|
||||
toolCall.result = result;
|
||||
sendRuntime(entry, sessionId, {
|
||||
type: done ? 'tool_end' : 'tool_update',
|
||||
toolUseId: itemId,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
result: safeResult,
|
||||
result,
|
||||
kind: toolCall.kind,
|
||||
meta: toolCall.meta,
|
||||
});
|
||||
@@ -911,10 +879,11 @@ function createCodexAppRuntime(deps = {}) {
|
||||
|
||||
case 'item/commandExecution/outputDelta': {
|
||||
const itemId = params.itemId;
|
||||
const current = entry.toolOutputDeltas?.get(itemId) || '';
|
||||
const next = appendCappedText(current, params.delta || '', RUNTIME_TOOL_DELTA_MAX_CHARS);
|
||||
const scopedItemId = itemIdForEntry(entry, itemId);
|
||||
const current = entry.toolOutputDeltas?.get(scopedItemId) || '';
|
||||
const next = appendFullText(current, params.delta || '');
|
||||
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
|
||||
entry.toolOutputDeltas.set(itemId, next);
|
||||
entry.toolOutputDeltas.set(scopedItemId, next);
|
||||
updateToolResult(entry, sessionId, itemId, next, false, {
|
||||
name: 'CommandExecution',
|
||||
kind: 'command_execution',
|
||||
@@ -966,10 +935,11 @@ function createCodexAppRuntime(deps = {}) {
|
||||
const itemId = params.itemId;
|
||||
const delta = String(params.delta || '');
|
||||
if (!itemId || !delta) return { done: false };
|
||||
const current = entry.toolOutputDeltas?.get(itemId) || '';
|
||||
const next = appendCappedText(current, delta, RUNTIME_TOOL_DELTA_MAX_CHARS);
|
||||
const scopedItemId = itemIdForEntry(entry, itemId);
|
||||
const current = entry.toolOutputDeltas?.get(scopedItemId) || '';
|
||||
const next = appendFullText(current, delta);
|
||||
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
|
||||
entry.toolOutputDeltas.set(itemId, next);
|
||||
entry.toolOutputDeltas.set(scopedItemId, next);
|
||||
updateToolResult(entry, sessionId, itemId, next, false, {
|
||||
name: 'Reasoning',
|
||||
kind: 'reasoning',
|
||||
@@ -987,7 +957,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
}
|
||||
if (item.type === 'userMessage') return { done: false };
|
||||
if (item.type === 'reasoning') {
|
||||
const result = truncateEnd(itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '', RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
const result = itemResult(item) || entry.toolOutputDeltas?.get(itemIdForEntry(entry, item.id)) || '';
|
||||
if (!result.trim()) return { done: false };
|
||||
const toolCall = ensureToolCall(entry, item, sessionId);
|
||||
if (!toolCall) return { done: false };
|
||||
@@ -1006,8 +976,8 @@ function createCodexAppRuntime(deps = {}) {
|
||||
const toolCall = ensureToolCall(entry, item, sessionId);
|
||||
if (!toolCall) return { done: false };
|
||||
const result = item.type === 'subAgentActivity'
|
||||
? safeStringifyPreview(toolCall.input, RUNTIME_TOOL_RESULT_MAX_CHARS)
|
||||
: truncateEnd(itemResult(item), RUNTIME_TOOL_RESULT_MAX_CHARS);
|
||||
? stringifyFullValue(toolCall.input)
|
||||
: itemResult(item);
|
||||
toolCall.done = true;
|
||||
toolCall.result = result;
|
||||
toolCall.meta = itemMeta(item) || toolCall.meta;
|
||||
@@ -1060,6 +1030,7 @@ function createCodexAppRuntime(deps = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
itemIdForEntry,
|
||||
planUpdateFromNotification,
|
||||
processCodexAppNotification,
|
||||
updateUsage,
|
||||
|
||||
338
lib/session-history-store.js
Normal file
338
lib/session-history-store.js
Normal file
@@ -0,0 +1,338 @@
|
||||
'use strict';
|
||||
|
||||
const nativeFs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const SESSION_RUNTIME_KEYS = new Set([
|
||||
'ws', 'tailer', 'codexAppStateTimer', 'codexAppStateDirty', 'codexAppStateCleaned',
|
||||
'toolOutputDeltas', 'agentMessageItems',
|
||||
]);
|
||||
|
||||
function createSessionHistoryStore(options = {}) {
|
||||
const fs = options.fs || nativeFs;
|
||||
const historyDir = path.join(options.sessionsDir, '_history');
|
||||
const turnsDir = path.join(historyDir, '_turns');
|
||||
// 身份只留在当前存储实例,不能通过 JSON 快照伪造可信完整加载或修订。
|
||||
const loadedStates = new WeakMap();
|
||||
const removedIds = new Set();
|
||||
|
||||
function validId(value) {
|
||||
const id = String(value || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw Object.assign(new Error('会话归档 ID 无效'), { code: 'history_invalid_id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
function filePath(id) {
|
||||
return path.join(historyDir, `${validId(id)}.json`);
|
||||
}
|
||||
|
||||
function readRecord(id) {
|
||||
let json;
|
||||
try {
|
||||
json = fs.readFileSync(filePath(id), 'utf8');
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
const record = JSON.parse(json);
|
||||
if (record?.version !== 1 || typeof record.revision !== 'string' || !record.revision
|
||||
|| record.session?.id !== id || !Array.isArray(record.session.messages)
|
||||
|| typeof record.complete !== 'boolean') {
|
||||
throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' });
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function state(session) {
|
||||
const value = session && loadedStates.get(session);
|
||||
if (!value) return null;
|
||||
const { baseline, ...metadata } = value;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function load(id) {
|
||||
const record = readRecord(validId(id));
|
||||
if (!record) return null;
|
||||
loadedStates.set(record.session, {
|
||||
id, revision: record.revision, complete: record.complete, source: 'archive',
|
||||
resetRevision: record.resetRevision || null, baseline: JSON.stringify(record.session),
|
||||
});
|
||||
return record.session;
|
||||
}
|
||||
|
||||
function markLegacy(session) {
|
||||
validId(session?.id);
|
||||
loadedStates.set(session, {
|
||||
id: session.id, revision: null, source: 'legacy',
|
||||
complete: !session.messages?.some(message => message?.ccwebPersistenceNotice === true)
|
||||
&& !(Number(session.historySnapshotBaseIndex) > 0),
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function markComplete(session, options = {}) {
|
||||
const previous = loadedStates.get(session);
|
||||
if (!previous || previous.id !== session.id) {
|
||||
throw Object.assign(new Error('只能确认可信加载会话的历史状态'), { code: 'history_untrusted_source' });
|
||||
}
|
||||
loadedStates.set(session, { ...previous, complete: true, reset: options.reset === true });
|
||||
session.historySnapshotBaseIndex = 0;
|
||||
session.historySnapshotCount = session.messages.length;
|
||||
return () => loadedStates.set(session, previous);
|
||||
}
|
||||
|
||||
function serializeSession(session) {
|
||||
// 完整档案没有文本、消息数、数组长度或深度裁剪;不可序列化时整次提交失败。
|
||||
const json = JSON.stringify(session, function replacer(key, value) {
|
||||
if (this === session && SESSION_RUNTIME_KEYS.has(key)) return undefined;
|
||||
if (typeof value === 'bigint') return String(value);
|
||||
if (value instanceof Map) return Object.fromEntries(value);
|
||||
if (value instanceof Set) return [...value];
|
||||
return value;
|
||||
});
|
||||
const persisted = JSON.parse(json);
|
||||
if (!Array.isArray(persisted.messages)) throw new Error('完整会话消息必须为数组');
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function equal(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function identifiedArray(value) {
|
||||
return Array.isArray(value) && value.every(item => item && typeof item.id === 'string' && item.id);
|
||||
}
|
||||
|
||||
function mergeIdentifiedArray(baseline, incoming, current) {
|
||||
const baseById = new Map(baseline.map(item => [item.id, item]));
|
||||
const incomingById = new Map(incoming.map(item => [item.id, item]));
|
||||
const currentById = new Map(current.map(item => [item.id, item]));
|
||||
const output = [];
|
||||
const seen = new Set();
|
||||
for (const item of current) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
const base = baseById.get(item.id);
|
||||
const next = incomingById.get(item.id);
|
||||
if (base && !next && equal(item, base)) continue;
|
||||
output.push(next ? mergeValue(base, next, item) : item);
|
||||
}
|
||||
// 只补本次相对加载基线新增的气泡;后来已经删除的旧气泡不能复活。
|
||||
for (let index = 0; index < incoming.length; index += 1) {
|
||||
const item = incoming[index];
|
||||
if (baseById.has(item.id) || currentById.has(item.id) || output.some(existing => existing.id === item.id)) continue;
|
||||
let position = -1;
|
||||
for (let next = index + 1; next < incoming.length; next += 1) {
|
||||
position = output.findIndex(existing => existing.id === incoming[next].id);
|
||||
if (position >= 0) break;
|
||||
}
|
||||
if (position < 0) {
|
||||
for (let previous = index - 1; previous >= 0; previous -= 1) {
|
||||
const anchor = output.findIndex(existing => existing.id === incoming[previous].id);
|
||||
if (anchor >= 0) { position = anchor + 1; break; }
|
||||
}
|
||||
}
|
||||
output.splice(position < 0 ? output.length : position, 0, item);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function mergeValue(baseline, incoming, current) {
|
||||
if (equal(incoming, baseline)) return current;
|
||||
if (equal(current, baseline) || equal(incoming, current)) return incoming;
|
||||
if ((baseline === undefined || identifiedArray(baseline)) && identifiedArray(incoming) && identifiedArray(current)) {
|
||||
return mergeIdentifiedArray(baseline || [], incoming, current);
|
||||
}
|
||||
if (plainObject(incoming) && plainObject(current) && (baseline === undefined || plainObject(baseline))) {
|
||||
const output = Object.create(null);
|
||||
const base = baseline || {};
|
||||
for (const key of new Set([...Object.keys(base), ...Object.keys(incoming), ...Object.keys(current)])) {
|
||||
const own = (value) => Object.prototype.hasOwnProperty.call(value, key) ? value[key] : undefined;
|
||||
const value = mergeValue(own(base), own(incoming), own(current));
|
||||
if (value !== undefined) output[key] = value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
// 同字段冲突保留已经提交的值;互不重叠的正文、工具与元数据修改会逐字段合并。
|
||||
return current;
|
||||
}
|
||||
|
||||
function synchronizeValue(target, persisted, root = false) {
|
||||
if (Array.isArray(target) && Array.isArray(persisted)) {
|
||||
const identified = identifiedArray(target) && identifiedArray(persisted);
|
||||
const byId = identified ? new Map(target.map(item => [item.id, item])) : null;
|
||||
const updated = persisted.map((item, index) => synchronizeValue(byId ? byId.get(item.id) : target[index], item));
|
||||
target.length = updated.length;
|
||||
for (let index = 0; index < updated.length; index += 1) target[index] = updated[index];
|
||||
return target;
|
||||
}
|
||||
const prototype = target && typeof target === 'object' ? Object.getPrototypeOf(target) : undefined;
|
||||
if ((prototype === Object.prototype || prototype === null) && plainObject(persisted)) {
|
||||
for (const key of Object.keys(target)) {
|
||||
if ((!root || !SESSION_RUNTIME_KEYS.has(key)) && !Object.prototype.hasOwnProperty.call(persisted, key)) delete target[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(persisted)) {
|
||||
const existing = Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined;
|
||||
Object.defineProperty(target, key, {
|
||||
value: synchronizeValue(existing, value), enumerable: true, writable: true, configurable: true,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function writeAtomicJson(target, record) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(temporary, 'wx', 0o600);
|
||||
fs.writeFileSync(fd, JSON.stringify(record), 'utf8');
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
fd = undefined;
|
||||
fs.renameSync(temporary, target);
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try { fs.closeSync(fd); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecord(id, record) {
|
||||
writeAtomicJson(filePath(id), record);
|
||||
}
|
||||
|
||||
function turnPath(id) {
|
||||
return path.join(turnsDir, `${validId(id)}.json`);
|
||||
}
|
||||
|
||||
function saveTurn(id, state) {
|
||||
writeAtomicJson(turnPath(id), state);
|
||||
}
|
||||
|
||||
function loadTurn(id) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(turnPath(id), 'utf8'));
|
||||
if (state?.sessionId !== id || state.agent !== 'codexapp') throw new Error('完整运行态归档无效');
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function removeTurn(id) {
|
||||
try { fs.unlinkSync(turnPath(id)); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function listTurnIds() {
|
||||
try {
|
||||
return fs.readdirSync(turnsDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name)).map(name => name.slice(0, -5));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function save(session) {
|
||||
const id = validId(session?.id);
|
||||
const previous = loadedStates.get(session);
|
||||
if (removedIds.has(id)) throw Object.assign(new Error('已删除会话不能由旧对象重新保存'), { code: 'history_removed' });
|
||||
const current = readRecord(id);
|
||||
if (current) {
|
||||
if (previous?.source !== 'archive' || previous.id !== id) {
|
||||
throw Object.assign(new Error('受限预览不能覆盖完整会话归档'), { code: 'history_untrusted_source' });
|
||||
}
|
||||
} else if (previous?.source === 'archive') {
|
||||
throw Object.assign(new Error('完整会话归档已移除,拒绝旧对象重新创建'), { code: 'history_removed' });
|
||||
}
|
||||
const complete = current?.complete || (previous ? previous.complete
|
||||
: !session.messages?.some(message => message?.ccwebPersistenceNotice === true)
|
||||
&& !(Number(session.historySnapshotBaseIndex) > 0));
|
||||
let persisted = serializeSession(session);
|
||||
const rebased = !!current && previous.revision !== current.revision;
|
||||
if (rebased) {
|
||||
const baseline = JSON.parse(previous.baseline);
|
||||
if (!identifiedArray(baseline.messages) || !identifiedArray(persisted.messages) || !identifiedArray(current.session.messages)) {
|
||||
throw Object.assign(new Error('旧修订缺少可靠消息 ID,拒绝覆盖新历史'), { code: 'history_stale_unidentified' });
|
||||
}
|
||||
if (baseline.messages.length > 0 && persisted.messages.length === 0 && !equal(current.session.messages, baseline.messages)) {
|
||||
throw Object.assign(new Error('清空前历史已经更新,请重新加载'), { code: 'history_stale_clear' });
|
||||
}
|
||||
if ((current.resetRevision || null) !== (previous.resetRevision || null)) {
|
||||
// 清空提交后旧运行对象只能同步元数据,旧线程的历史和输出不能复活。
|
||||
persisted.messages = baseline.messages;
|
||||
for (const key of ['claudeSessionId', 'codexThreadId', 'codexAppThreadId', 'historySnapshotBaseIndex', 'historySnapshotCount']) {
|
||||
if (Object.prototype.hasOwnProperty.call(baseline, key)) persisted[key] = baseline[key];
|
||||
else delete persisted[key];
|
||||
}
|
||||
}
|
||||
persisted = mergeValue(baseline, persisted, current.session);
|
||||
}
|
||||
if (complete) {
|
||||
persisted.historySnapshotBaseIndex = 0;
|
||||
persisted.historySnapshotCount = persisted.messages.length;
|
||||
}
|
||||
const revision = crypto.randomUUID();
|
||||
const resetRevision = previous?.reset ? revision : current?.resetRevision || null;
|
||||
const record = { version: 1, revision, complete, resetRevision, session: persisted };
|
||||
writeRecord(id, record);
|
||||
// 只有原子提交后才推进修订,失败重试仍以原修订为基线。
|
||||
loadedStates.set(session, { id, revision, complete, resetRevision, source: 'archive', baseline: JSON.stringify(persisted) });
|
||||
if (rebased) {
|
||||
// 流式处理闭包可能持有气泡和工具对象,合并时按 ID 原位协调,保持后续 delta 写入目标。
|
||||
synchronizeValue(session, persisted, true);
|
||||
}
|
||||
return { revision, complete, session: persisted, rebased };
|
||||
}
|
||||
|
||||
function listIds() {
|
||||
try {
|
||||
return fs.readdirSync(historyDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name))
|
||||
.map(name => name.slice(0, -5));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function revision(id) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath(id), 'r');
|
||||
const head = Buffer.alloc(256);
|
||||
const count = fs.readSync(fd, head, 0, head.length, 0);
|
||||
const matched = /^\{"version":1,"revision":"([0-9a-f-]{36})",/.exec(head.toString('utf8', 0, count));
|
||||
if (!matched) throw new Error('完整会话归档修订无效');
|
||||
return matched[1];
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
id = validId(id);
|
||||
try { fs.unlinkSync(filePath(id)); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
removedIds.add(id);
|
||||
removeTurn(id);
|
||||
}
|
||||
|
||||
return { load, save, state, markLegacy, markComplete, listIds, revision, remove, saveTurn, loadTurn, removeTurn, listTurnIds, historyDir };
|
||||
}
|
||||
|
||||
module.exports = { createSessionHistoryStore };
|
||||
Reference in New Issue
Block a user