修复 Codex rollout 历史合并并重新打包

This commit is contained in:
shiyue
2026-09-20 19:34:30 +08:00
parent 9242e79520
commit db04e088bc
11 changed files with 615 additions and 49 deletions

View File

@@ -16,7 +16,7 @@ const CCWEB_REPLY_MODES = Object.freeze({
ONE_WAY: 'one_way',
RETURN_AND_CONTINUE: 'return_and_continue',
});
const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时,必须使用 return_and_continue。工具调用会立即返回,不阻塞也不等待目标对话完成。';
const CCWEB_SEND_MESSAGE_DESCRIPTION = '向指定 ccweb 对话发送一条消息,并以“来自某对话”的气泡在目标对话中展示。必须填写 replyMode:仅当来源不需要目标结果时使用 one_way;涉及分析、实现、测试、验收、完成后汇报或来源后续依赖目标结果时必须使用 return_and_continue。两种模式的工具调用都会立即返回,不阻塞也不等待目标对话完成;return_and_continue 只表示目标完成后由系统异步回传并触发来源继续运行,不表示当前调用需要等待。调用后不要使用 sleep、不要轮询等待,也不要反复查询 pending reply;继续当前工作或结束本轮即可。';
const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([
'ccweb_list_conversations',
'ccweb_list_user_inputs',
@@ -175,7 +175,7 @@ const TOOLS = [
replyMode: {
type: 'string',
enum: Object.values(CCWEB_REPLY_MODES),
description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后自动回传结果并触发来源继续运行。',
description: '回传模式。one_way 表示单向投递且目标输出只留在目标对话;return_and_continue 表示目标完成后由系统异步回传结果并触发来源继续运行。两种模式都立即返回,不要 sleep 或轮询等待。',
},
},
required: ['targetConversationId', 'content', 'replyMode'],

View File

@@ -18,6 +18,20 @@ function createCodexRolloutStore(deps) {
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
}
function stableHash(value) {
return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 24);
}
function extractTurnKey(entry, payload, fallback) {
const context = payload?.turn_context || entry?.turn_context || {};
const value = entry?.turn_id
|| payload?.turn_id
|| context?.turn_id
|| context?.id
|| null;
return value ? String(value) : fallback;
}
function extractCcwebSourceConversation(text) {
const match = String(text || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/);
if (!match) return null;
@@ -39,6 +53,8 @@ function createCodexRolloutStore(deps) {
};
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
let currentAssistant = null;
let currentTurnKey = null;
let implicitTurnSequence = 0;
let sawRealUserMessage = false;
const fallbackUserMessages = [];
@@ -50,9 +66,21 @@ function createCodexRolloutStore(deps) {
meta.sourceConversationTitle = sourceConversation.title;
}
function ensureAssistant(ts) {
function ensureAssistant(ts, turnKey = null) {
// 没有 turn_id 的旧 rollout 仍需在每次新助手输出时生成新的隐式轮次键。
// 否则不同轮次会共用同一个 nativeTurnKey,前端按稳定键去重时会误删后续回复。
const resolvedTurnKey = turnKey || currentTurnKey || `${meta.threadId || 'native'}:implicit:${implicitTurnSequence += 1}`;
if (currentAssistant && currentTurnKey !== resolvedTurnKey) flushAssistant();
currentTurnKey = resolvedTurnKey;
if (!currentAssistant) {
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
currentAssistant = {
role: 'assistant',
content: '',
toolCalls: [],
timestamp: ts || null,
nativeTurnKey: `native:${meta.threadId || 'unknown-thread'}:${currentTurnKey}`,
id: `native:${stableHash(`${meta.threadId || ''}:${currentTurnKey}`)}`,
};
} else if (!currentAssistant.timestamp && ts) {
currentAssistant.timestamp = ts;
}
@@ -65,6 +93,7 @@ function createCodexRolloutStore(deps) {
messages.push(currentAssistant);
}
currentAssistant = null;
currentTurnKey = null;
pendingToolCalls.clear();
}
@@ -84,6 +113,24 @@ function createCodexRolloutStore(deps) {
continue;
}
if (entry.type === 'event_msg' && ['task_started', 'turn_started'].includes(entry.payload?.type)) {
flushAssistant();
currentTurnKey = extractTurnKey(entry, entry.payload, `${meta.threadId || 'native'}:turn:${implicitTurnSequence += 1}`);
continue;
}
if (entry.type === 'turn_context') {
const turnKey = extractTurnKey(entry, entry.payload, currentTurnKey || `${meta.threadId || 'native'}:turn:${implicitTurnSequence += 1}`);
if (currentAssistant && currentTurnKey !== turnKey) flushAssistant();
currentTurnKey = turnKey;
continue;
}
if (entry.type === 'event_msg' && ['task_complete', 'turn_completed', 'turn_failed', 'turn_aborted'].includes(entry.payload?.type)) {
flushAssistant();
continue;
}
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
const total = entry.payload?.info?.total_token_usage || null;
const usage = entry.payload?.info?.last_token_usage || null;
@@ -104,9 +151,18 @@ function createCodexRolloutStore(deps) {
if (text) {
sawRealUserMessage = true;
flushAssistant();
const sourceConversation = extractCcwebSourceConversation(text);
rememberSourceConversation(text);
// cc-web 跨对话投递在 native rollout 中会留下内部 user 输入。
// 目标会话已经保存了带来源的消息,这里不再伪造普通 user 气泡。
if (sourceConversation) continue;
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
messages.push({ role: 'user', content: text, timestamp: ts });
messages.push({
role: 'user',
content: text,
timestamp: ts,
id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`,
});
}
continue;
}
@@ -119,22 +175,26 @@ function createCodexRolloutStore(deps) {
if (payload.role === 'assistant') {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
flushAssistant();
}
appendAssistantContent(ensureAssistant(ts), text);
appendAssistantContent(ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey)), text);
}
} else if (payload.role === 'user' && !sawRealUserMessage) {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
rememberSourceConversation(text);
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
if (!extractCcwebSourceConversation(text)) {
fallbackUserMessages.push({
role: 'user',
content: text,
timestamp: ts,
id: `native-user:${stableHash(`${meta.threadId || ''}:${ts || ''}:${text}`)}`,
});
}
}
}
break;
}
case 'function_call': {
const assistant = ensureAssistant(ts);
const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey));
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
const tc = {
name: payload.name || 'FunctionCall',
@@ -147,7 +207,7 @@ function createCodexRolloutStore(deps) {
break;
}
case 'function_call_output': {
const assistant = ensureAssistant(ts);
const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey));
const toolUseId = payload.call_id || crypto.randomUUID();
let tc = pendingToolCalls.get(toolUseId);
if (!tc) {
@@ -161,6 +221,34 @@ function createCodexRolloutStore(deps) {
: JSON.stringify(payload.output || '')).slice(0, 2000);
break;
}
case 'custom_tool_call': {
const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey));
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
const tc = {
name: payload.name || 'CustomToolCall',
id: toolUseId,
input: sanitizeToolInput(payload.name || 'CustomToolCall', payload.input || payload.arguments || ''),
done: payload.status === 'completed',
};
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
break;
}
case 'custom_tool_call_output': {
const assistant = ensureAssistant(ts, extractTurnKey(entry, payload, currentTurnKey));
const toolUseId = payload.call_id || crypto.randomUUID();
let tc = pendingToolCalls.get(toolUseId);
if (!tc) {
tc = { name: 'CustomToolCall', id: toolUseId, input: null, done: false };
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
}
tc.done = true;
tc.result = (typeof payload.output === 'string'
? payload.output
: JSON.stringify(payload.output || '')).slice(0, 2000);
break;
}
default:
break;
}