diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz
index 6d2ffce..191fc6c 100644
Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ
diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js
index ddaef99..41e8068 100644
--- a/lib/codex-app-runtime.js
+++ b/lib/codex-app-runtime.js
@@ -22,6 +22,8 @@ const RUNTIME_STREAM_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STRE
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']);
function createCodexAppRuntime(deps = {}) {
const {
@@ -131,6 +133,123 @@ function createCodexAppRuntime(deps = {}) {
return true;
}
+ function normalizeIdentifier(value) {
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ }
+
+ function isPlanToolName(value) {
+ const name = normalizeIdentifier(value);
+ return CODEX_APP_PLAN_TOOL_NAMES.has(name) || name.endsWith('_update_plan');
+ }
+
+ function isPlanLikeItem(item) {
+ if (!item || typeof item !== 'object') return false;
+ if (CODEX_APP_PLAN_ITEM_TYPES.has(normalizeIdentifier(item.type))) return true;
+ return isPlanToolName(item.tool || item.name || item.functionName || item.function?.name);
+ }
+
+ function parseMaybeJsonValue(value) {
+ if (typeof value !== 'string') return value;
+ const trimmed = value.trim();
+ if (!trimmed || !/^[{[]/.test(trimmed)) return value;
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return value;
+ }
+ }
+
+ function extractPlanEntries(value, depth = 0) {
+ if (value === null || value === undefined || depth > 3) return null;
+ const source = parseMaybeJsonValue(value);
+ if (Array.isArray(source)) return source;
+ if (!source || typeof source !== 'object') return null;
+ const keys = ['plan', 'items', 'todos', 'tasks', 'steps'];
+ for (const key of keys) {
+ if (Array.isArray(source[key])) return source[key];
+ }
+ const nestedKeys = ['arguments', 'input', 'params', 'payload', 'structuredContent', 'result'];
+ for (const key of nestedKeys) {
+ const nested = extractPlanEntries(source[key], depth + 1);
+ if (nested) return nested;
+ }
+ if (Array.isArray(source.contentItems)) {
+ for (const part of source.contentItems) {
+ const text = typeof part?.text === 'string' ? part.text : '';
+ const nested = extractPlanEntries(text, depth + 1);
+ if (nested) return nested;
+ }
+ }
+ return null;
+ }
+
+ function planEntryCompleted(entry) {
+ if (!entry || typeof entry !== 'object') return false;
+ if (entry.completed === true || entry.done === true) return true;
+ const status = normalizeIdentifier(entry.status || entry.state);
+ return ['completed', 'complete', 'done', 'success', 'succeeded'].includes(status);
+ }
+
+ function planEntryText(entry) {
+ if (typeof entry === 'string') return entry;
+ if (!entry || typeof entry !== 'object') return '';
+ return entry.step || entry.text || entry.title || entry.name || entry.description || entry.task || entry.item || entry.content || '';
+ }
+
+ function normalizeTodoListFromPlanItem(item) {
+ if (!isPlanLikeItem(item)) return null;
+ const candidates = [
+ item.arguments,
+ item.input,
+ item.params,
+ item.payload,
+ item.structuredContent,
+ item.result?.structuredContent,
+ item.result,
+ item,
+ ];
+ let entries = null;
+ for (const candidate of candidates) {
+ entries = extractPlanEntries(candidate);
+ if (entries) break;
+ }
+ if (!Array.isArray(entries)) return null;
+ const items = entries
+ .map((entry) => {
+ const text = truncateEnd(planEntryText(entry), RUNTIME_TOOL_INPUT_MAX_CHARS);
+ if (!text) return null;
+ return {
+ text,
+ completed: planEntryCompleted(entry),
+ };
+ })
+ .filter(Boolean);
+ return {
+ id: item.id || item.itemId || item.planId || 'codex-app-plan',
+ type: 'todo_list',
+ items,
+ };
+ }
+
+ function planUpdateItemFromParams(params = {}) {
+ const item = params.item && typeof params.item === 'object' ? { ...params.item } : {};
+ return {
+ ...params,
+ ...item,
+ id: item.id || params.itemId || params.id || params.planId || 'codex-app-plan',
+ type: item.type || params.type || 'planList',
+ status: item.status || params.status || 'inProgress',
+ plan: item.plan || params.plan,
+ items: item.items || params.items,
+ todos: item.todos || params.todos,
+ tasks: item.tasks || params.tasks,
+ };
+ }
+
function codexAppErrorMessage(value) {
if (!value) return '';
if (typeof value === 'string') return value;
@@ -178,6 +297,7 @@ function createCodexAppRuntime(deps = {}) {
}
function itemKind(item) {
+ if (normalizeTodoListFromPlanItem(item)) return 'todo_list';
switch (item?.type) {
case 'commandExecution':
return 'command_execution';
@@ -203,6 +323,7 @@ function createCodexAppRuntime(deps = {}) {
}
function itemName(item) {
+ if (normalizeTodoListFromPlanItem(item)) return 'PlanList';
switch (item?.type) {
case 'commandExecution':
return 'CommandExecution';
@@ -227,6 +348,8 @@ function createCodexAppRuntime(deps = {}) {
function itemInput(item) {
if (!item) return null;
+ const todoList = normalizeTodoListFromPlanItem(item);
+ if (todoList) return todoList;
switch (item.type) {
case 'commandExecution':
return { command: truncateEnd(item.command || '', RUNTIME_TOOL_INPUT_MAX_CHARS) };
@@ -292,6 +415,14 @@ function createCodexAppRuntime(deps = {}) {
function itemMeta(item) {
if (!item) return null;
+ if (normalizeTodoListFromPlanItem(item)) {
+ return {
+ kind: 'todo_list',
+ title: 'Plan List',
+ subtitle: item.explanation || item.title || item.tool || '',
+ status: item.status || null,
+ };
+ }
switch (item.type) {
case 'commandExecution':
return {
@@ -346,6 +477,8 @@ function createCodexAppRuntime(deps = {}) {
function itemResult(item) {
if (!item) return '';
+ const todoList = normalizeTodoListFromPlanItem(item);
+ if (todoList) return JSON.stringify(todoList, null, 2);
switch (item.type) {
case 'commandExecution':
return truncateEnd(item.aggregatedOutput || '', RUNTIME_TOOL_RESULT_MAX_CHARS);
@@ -390,7 +523,7 @@ function createCodexAppRuntime(deps = {}) {
toolCall.name = itemName(item);
toolCall.kind = kind;
toolCall.meta = itemMeta(item) || toolCall.meta || null;
- if (toolCall.input == null) toolCall.input = itemInput(item);
+ if (toolCall.input == null || kind === 'todo_list') toolCall.input = itemInput(item);
return toolCall;
}
@@ -608,6 +741,22 @@ function createCodexAppRuntime(deps = {}) {
return { done: false };
}
+ case 'plan/updated':
+ case 'turn/plan/updated':
+ case 'item/plan/updated':
+ case 'item/todoList/updated': {
+ const item = planUpdateItemFromParams(params);
+ const todoList = normalizeTodoListFromPlanItem(item);
+ if (!todoList) return { done: false };
+ updateToolResult(entry, sessionId, todoList.id, JSON.stringify(todoList, null, 2), false, {
+ name: 'PlanList',
+ kind: 'todo_list',
+ input: todoList,
+ meta: itemMeta(item),
+ });
+ return { done: false };
+ }
+
case 'item/reasoning/summaryTextDelta':
case 'item/reasoning/textDelta': {
const itemId = params.itemId;
diff --git a/public/rag-for-pm-v2.html b/public/rag-for-pm-v2.html
new file mode 100644
index 0000000..f09388e
--- /dev/null
+++ b/public/rag-for-pm-v2.html
@@ -0,0 +1,447 @@
+
+
+
+
+
+RAG 入门:原理、流程与使用
+
+
+
+
+
+
+
+
RAG Primer原理和使用
+
RAG · LLM · Retrieval · Prompt · Agent · MCP
+
RAG 入门:让 AI 先查资料再回答
+
从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。
+
+ RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP
+
+
+
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
+
+
+
+
+
+
+
Roadmap学习路径
+
由浅入深
+
从“会生成”到“会查资料、会调用工具”
+
+
+
→
+
+
→
+
+
→
+
+
→
+
+
+
+
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
+
+
+
+
+
+
+
RAG检索增强生成
+
RAG 解释
+
RAG = 先检索资料,再增强上下文,最后生成答案
+
+
+
不是训练模型
知识不写进模型参数,而是每次回答前动态查资料。
+
不是全量塞资料
只取与问题相关的片段,降低上下文压力和噪声。
+
+
+
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
+
+
+
+
+
+
+
LLM大语言模型
+
定义
+
LLM:理解上下文,生成自然语言
+
+
+
能力
+
理解上下文,生成答案
+
+ - 读懂用户问题的大意
+ - 把零散信息组织成自然语言
+ - 按要求改写、总结、解释、翻译
+
+
+
≠
+
+
边界
+
企业资料库或事实系统
+
+ - 不会天然知道最新制度
+ - 不知道内部文档和私有数据
+ - 不能保证每句话都有来源
+
+
+
+
定位
LLM 负责读懂和表达,事实依据来自外部资料。
+
+
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
+
+
+
+
+
+
+
Limits对话限制
+
对话限制
+
LLM 的 4 个对话限制
+
+
1
上下文有限
输入窗口有限;资料过多会变慢、变贵、变乱。
+
2
会有幻觉
资料不足或指令不清时,会生成看似合理的错误内容。
+
3
专注度下降
长资料和噪声会稀释重点,关键信息可能被忽略。
+
4
顺序不稳定
位置、相似内容、前后冲突都会影响答案。
+
+
+
+
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
+
+
+
+
+
+
+
Why RAG限制带来方案
+
从限制到方案
+
RAG 的核心:不是让模型记住全部知识,而是让它按需查资料
+
+
全量输入全部塞给 LLM
长、乱、贵,容易混入过期和无权限资料。
+
→
+
+
→
+
生成基于资料回答
LLM 根据资料生成,必要时引用来源。
+
+
RAG = 检索增强生成
检索相关资料 → 放入上下文 → LLM 生成答案。
+
+
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
+
+
+
+
+
+
+
Offline Pipeline后台整理资料
+
离线流程
+
资料整理成可检索的知识库
+
+
01收集资料
产品手册、FAQ、制度、接口文档、案例、流程说明。
+
02清洗资料
去掉重复、过期、广告、目录噪声和格式错误。
+
+
04加元数据
来源、版本、时间、部门、权限、适用范围。
+
05向量化入库
把每个片段变成语义向量,写入向量库。
+
+
+
+
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
+
+
+
+
+
+
+
Chunk & Embedding切片和向量化
+
索引构建
+
切片 + 向量化:支持语义检索
+
+
切片 A:退费条件购买后 7 天内,且未使用核心服务,可以申请全额退款。
+
切片 B:不可退场景已开具发票、已交付定制服务、超过合同期限,不支持自动退款。
+
切片 C:审批路径超过 5 万元的退款申请,需要客户成功经理和财务双审批。
+
+
+
切片粒度适中
过大噪声多,过小语义断;每片覆盖一个局部问题。
+
向量库语义坐标
文字转换成数字向量;语义相近,距离更近。
+
+
+
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
+
+
+
+
+
+
+
Online Retrieval检索和排序
+
在线流程
+
用户提问后:检索候选,排序后交给 LLM
+
+
+
+
+
04排序 / 重排
按相关性、时效、权限、来源可信度重新排序。
+
+
+
+
排序后的候选资料
2
大客户审批流程
相关,但只在金额超过 5 万时使用。
+
排序作用
- 过期资料降权
- 无权限资料过滤
- 可信来源优先
- 减少噪声,保留重点
+
+
+
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
+
+
+
+
+
+
+
Prompt Engineering系统提示词
+
生成约束
+
系统提示词规定 LLM 的资料使用规则
+
+
+
SYSTEM:
+
你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。
+
+
CONTEXT:
+
[退款政策 v2026Q2] 购买后 7 天内且未使用核心服务,可全额退款。
+
+
USER:
+
客户买了 3 天,还没使用,可以退吗?
+
+
+
提示词工程
+
规则
+
+ - 角色:你是谁
+ - 边界:只能基于资料回答
+ - 格式:分点、引用来源、给结论
+ - 兜底:不知道就说不知道
+ - 安全:不要泄露无权限信息
+
+
+
+
+
+
这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。
+
+
+
+
+
+
+
Agent复杂任务的分工协作
+
复杂任务
+
复杂任务:多步骤、多角色、多 LLM 协作
+
+
+
→
+
检索者查资料
使用 RAG 从知识库里找依据,必要时多轮检索。
+
→
+
执行者调用工具
查订单、建工单、读数据库、调用业务系统接口。
+
+
+
Agent
目标驱动流程:规划步骤、选择工具、读取结果、继续推进。
+
和 RAG 的关系
RAG 提供知识入口;Agent 负责任务编排。
+
+
+
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
+
+
+
+
+
+
+
MCP工具连接
+
MCP
+
MCP:让 Agent 稳定连接外部工具和业务系统
+
+
统一接口工具接入规范
把不同系统的能力包装成模型可调用的工具。
+
上下文供给读取外部信息
查数据库、读文件、取工单、访问知识系统。
+
动作执行调用业务能力
创建工单、查询订单、发送通知、写入结果。
+
+
+ CRM工单数据库搜索文件
+
+
RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。
+
+
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
+
+
+
+
+
+
+
Takeaway关键链路
+
核心链路
+
RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP
+
+
限制LLM 不能吃下全部知识
上下文有限、会幻觉、专注度和顺序都不稳定。
+
建库资料要先整理成片段
清洗、切片、向量化、加元数据,再放入向量库。
+
检索提问时先找资料
召回候选,再排序过滤,把最相关内容放进上下文。
+
扩展Agent + MCP
Agent 编排多步骤任务;MCP 连接外部工具和系统。
+
+
RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。
+
+
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
+
+
+
+
+
+
+
+
+ 1 / 13
+
+
+
+
+
diff --git a/public/rag-for-pm.html b/public/rag-for-pm.html
new file mode 100644
index 0000000..f09388e
--- /dev/null
+++ b/public/rag-for-pm.html
@@ -0,0 +1,447 @@
+
+
+
+
+
+RAG 入门:原理、流程与使用
+
+
+
+
+
+
+
+
RAG Primer原理和使用
+
RAG · LLM · Retrieval · Prompt · Agent · MCP
+
RAG 入门:让 AI 先查资料再回答
+
从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。
+
+ RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP
+
+
+
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
+
+
+
+
+
+
+
Roadmap学习路径
+
由浅入深
+
从“会生成”到“会查资料、会调用工具”
+
+
+
→
+
+
→
+
+
→
+
+
→
+
+
+
+
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
+
+
+
+
+
+
+
RAG检索增强生成
+
RAG 解释
+
RAG = 先检索资料,再增强上下文,最后生成答案
+
+
+
不是训练模型
知识不写进模型参数,而是每次回答前动态查资料。
+
不是全量塞资料
只取与问题相关的片段,降低上下文压力和噪声。
+
+
+
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
+
+
+
+
+
+
+
LLM大语言模型
+
定义
+
LLM:理解上下文,生成自然语言
+
+
+
能力
+
理解上下文,生成答案
+
+ - 读懂用户问题的大意
+ - 把零散信息组织成自然语言
+ - 按要求改写、总结、解释、翻译
+
+
+
≠
+
+
边界
+
企业资料库或事实系统
+
+ - 不会天然知道最新制度
+ - 不知道内部文档和私有数据
+ - 不能保证每句话都有来源
+
+
+
+
定位
LLM 负责读懂和表达,事实依据来自外部资料。
+
+
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
+
+
+
+
+
+
+
Limits对话限制
+
对话限制
+
LLM 的 4 个对话限制
+
+
1
上下文有限
输入窗口有限;资料过多会变慢、变贵、变乱。
+
2
会有幻觉
资料不足或指令不清时,会生成看似合理的错误内容。
+
3
专注度下降
长资料和噪声会稀释重点,关键信息可能被忽略。
+
4
顺序不稳定
位置、相似内容、前后冲突都会影响答案。
+
+
+
+
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
+
+
+
+
+
+
+
Why RAG限制带来方案
+
从限制到方案
+
RAG 的核心:不是让模型记住全部知识,而是让它按需查资料
+
+
全量输入全部塞给 LLM
长、乱、贵,容易混入过期和无权限资料。
+
→
+
+
→
+
生成基于资料回答
LLM 根据资料生成,必要时引用来源。
+
+
RAG = 检索增强生成
检索相关资料 → 放入上下文 → LLM 生成答案。
+
+
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
+
+
+
+
+
+
+
Offline Pipeline后台整理资料
+
离线流程
+
资料整理成可检索的知识库
+
+
01收集资料
产品手册、FAQ、制度、接口文档、案例、流程说明。
+
02清洗资料
去掉重复、过期、广告、目录噪声和格式错误。
+
+
04加元数据
来源、版本、时间、部门、权限、适用范围。
+
05向量化入库
把每个片段变成语义向量,写入向量库。
+
+
+
+
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
+
+
+
+
+
+
+
Chunk & Embedding切片和向量化
+
索引构建
+
切片 + 向量化:支持语义检索
+
+
切片 A:退费条件购买后 7 天内,且未使用核心服务,可以申请全额退款。
+
切片 B:不可退场景已开具发票、已交付定制服务、超过合同期限,不支持自动退款。
+
切片 C:审批路径超过 5 万元的退款申请,需要客户成功经理和财务双审批。
+
+
+
切片粒度适中
过大噪声多,过小语义断;每片覆盖一个局部问题。
+
向量库语义坐标
文字转换成数字向量;语义相近,距离更近。
+
+
+
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
+
+
+
+
+
+
+
Online Retrieval检索和排序
+
在线流程
+
用户提问后:检索候选,排序后交给 LLM
+
+
+
+
+
04排序 / 重排
按相关性、时效、权限、来源可信度重新排序。
+
+
+
+
排序后的候选资料
2
大客户审批流程
相关,但只在金额超过 5 万时使用。
+
排序作用
- 过期资料降权
- 无权限资料过滤
- 可信来源优先
- 减少噪声,保留重点
+
+
+
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
+
+
+
+
+
+
+
Prompt Engineering系统提示词
+
生成约束
+
系统提示词规定 LLM 的资料使用规则
+
+
+
SYSTEM:
+
你是客服助手。只能基于 CONTEXT 回答;资料不足时说“不确定”,不要编造。
+
+
CONTEXT:
+
[退款政策 v2026Q2] 购买后 7 天内且未使用核心服务,可全额退款。
+
+
USER:
+
客户买了 3 天,还没使用,可以退吗?
+
+
+
提示词工程
+
规则
+
+ - 角色:你是谁
+ - 边界:只能基于资料回答
+ - 格式:分点、引用来源、给结论
+ - 兜底:不知道就说不知道
+ - 安全:不要泄露无权限信息
+
+
+
+
+
+
这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。
+
+
+
+
+
+
+
Agent复杂任务的分工协作
+
复杂任务
+
复杂任务:多步骤、多角色、多 LLM 协作
+
+
+
→
+
检索者查资料
使用 RAG 从知识库里找依据,必要时多轮检索。
+
→
+
执行者调用工具
查订单、建工单、读数据库、调用业务系统接口。
+
+
+
Agent
目标驱动流程:规划步骤、选择工具、读取结果、继续推进。
+
和 RAG 的关系
RAG 提供知识入口;Agent 负责任务编排。
+
+
+
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
+
+
+
+
+
+
+
MCP工具连接
+
MCP
+
MCP:让 Agent 稳定连接外部工具和业务系统
+
+
统一接口工具接入规范
把不同系统的能力包装成模型可调用的工具。
+
上下文供给读取外部信息
查数据库、读文件、取工单、访问知识系统。
+
动作执行调用业务能力
创建工单、查询订单、发送通知、写入结果。
+
+
+ CRM工单数据库搜索文件
+
+
RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。
+
+
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
+
+
+
+
+
+
+
Takeaway关键链路
+
核心链路
+
RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP
+
+
限制LLM 不能吃下全部知识
上下文有限、会幻觉、专注度和顺序都不稳定。
+
建库资料要先整理成片段
清洗、切片、向量化、加元数据,再放入向量库。
+
检索提问时先找资料
召回候选,再排序过滤,把最相关内容放进上下文。
+
扩展Agent + MCP
Agent 编排多步骤任务;MCP 连接外部工具和系统。
+
+
RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。
+
+
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
+
+
+
+
+
+
+
+
+ 1 / 13
+
+
+
+
+
diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js
index ff49bdf..06a6d33 100755
--- a/scripts/mock-codex-app-server.js
+++ b/scripts/mock-codex-app-server.js
@@ -24,6 +24,7 @@ if (args[0] !== 'app-server') {
const threads = new Map();
const pendingServerRequests = new Map();
+const resumeMismatchThreads = new Set();
let nextServerRequestId = 1;
let mcpReloadCount = 0;
@@ -64,6 +65,12 @@ function tokenUsage(text) {
};
}
+function retryScenarioKey(text, marker) {
+ return new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(String(text || ''))
+ ? marker
+ : String(text || '');
+}
+
function collaborationSummary(params = {}) {
const collaborationMode = params.collaborationMode;
const settings = collaborationMode?.settings || {};
@@ -623,8 +630,9 @@ function startTurn(params) {
}
if (/codexapp capacity retry/i.test(text)) {
- const attempts = (thread.capacityRetryAttempts.get(text) || 0) + 1;
- thread.capacityRetryAttempts.set(text, attempts);
+ const retryKey = retryScenarioKey(text, 'codexapp capacity retry');
+ const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
+ thread.capacityRetryAttempts.set(retryKey, attempts);
if (attempts <= 2) {
if (attempts === 2) emitPartialCapacityOutput(thread, turnId);
emitCapacityError(thread, turnId);
@@ -633,8 +641,9 @@ function startTurn(params) {
}
if (/codexapp reconnect retry/i.test(text)) {
- const attempts = (thread.reconnectRetryAttempts.get(text) || 0) + 1;
- thread.reconnectRetryAttempts.set(text, attempts);
+ const retryKey = retryScenarioKey(text, 'codexapp reconnect retry');
+ const attempts = (thread.reconnectRetryAttempts.get(retryKey) || 0) + 1;
+ thread.reconnectRetryAttempts.set(retryKey, attempts);
if (attempts === 1) {
emitPartialCapacityOutput(thread, turnId);
send({
@@ -650,6 +659,17 @@ function startTurn(params) {
}
}
+ if (/codexapp retry thread mismatch/i.test(text)) {
+ const retryKey = retryScenarioKey(text, 'codexapp retry thread mismatch');
+ const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1;
+ thread.capacityRetryAttempts.set(retryKey, attempts);
+ if (attempts === 1) {
+ resumeMismatchThreads.add(thread.id);
+ emitCapacityError(thread, turnId);
+ return { turn: { id: turnId, status: 'running', items: [] } };
+ }
+ }
+
if (/collaboration/i.test(text)) {
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
return { turn: { id: turnId, status: 'running', items: [] } };
@@ -787,6 +807,11 @@ function handleRequest(message) {
return;
}
if (method === 'thread/resume') {
+ if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
+ const thread = ensureThread(null, params);
+ send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
+ return;
+ }
const thread = ensureThread(params.threadId, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
diff --git a/scripts/regression.js b/scripts/regression.js
index d9f597b..502dcae 100644
--- a/scripts/regression.js
+++ b/scripts/regression.js
@@ -1311,17 +1311,21 @@ async function main() {
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry');
+ assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1');
+ assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode');
const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/自动重试/.test(msg.message || '')
), 10000);
assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output');
+ assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText);
assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message');
assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response');
+ assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt');
const codexAppReconnectRetryText = 'codexapp reconnect retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
@@ -1331,11 +1335,40 @@ async function main() {
/自动重试/.test(msg.message || '')
), 10000);
assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry');
+ assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds');
+ assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText);
assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message');
assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response');
+ assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt');
+
+ const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId;
+ assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread');
+ const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt';
+ ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
+ const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'system_message' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ /自动重试/.test(msg.message || '')
+ ), 10000);
+ assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry');
+ assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn');
+ assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry');
+ const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => (
+ msg.type === 'error' &&
+ msg.sessionId === codexAppSession.sessionId &&
+ /不同线程/.test(msg.message || '') &&
+ /上下文丢失/.test(msg.message || '')
+ ), 20000);
+ assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread');
+ await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000);
+ const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
+ assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id');
+ const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText);
+ assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message');
+ assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread');
ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
diff --git a/server.js b/server.js
index 1511bf2..9b42e4e 100644
--- a/server.js
+++ b/server.js
@@ -4689,6 +4689,77 @@ function hasRuntimeOutput(entry) {
return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0;
}
+function retryTailText(value, maxChars) {
+ const text = String(value || '').trim();
+ if (!text || text.length <= maxChars) return text;
+ const marker = '[cc-web: 前文过长,下面仅保留尾部]\n';
+ return `${marker}${text.slice(Math.max(0, text.length - Math.max(0, maxChars - marker.length)))}`;
+}
+
+function retryPreviewValue(value, maxChars) {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'string') return retryTailText(value, maxChars);
+ try {
+ return retryTailText(JSON.stringify(sanitizePersistValue(value, {
+ maxString: maxChars,
+ maxDepth: 4,
+ maxArray: 20,
+ maxKeys: 40,
+ }), null, 2), maxChars);
+ } catch {
+ return retryTailText(String(value), maxChars);
+ }
+}
+
+function buildCodexRetryToolSummary(toolCalls) {
+ const list = Array.isArray(toolCalls) ? toolCalls.filter(Boolean).slice(-8) : [];
+ if (list.length === 0) return '';
+ return list.map((tool, index) => {
+ const name = tool.name || tool.kind || tool.id || `tool-${index + 1}`;
+ const status = tool.done ? 'done' : (tool.status || tool.meta?.status || 'inProgress');
+ const lines = [`${index + 1}. ${name} (${status})`];
+ if (tool.input !== undefined && tool.input !== null) {
+ lines.push(` input: ${retryPreviewValue(tool.input, 1200)}`);
+ }
+ if (tool.result !== undefined && tool.result !== null) {
+ lines.push(` result: ${retryPreviewValue(tool.result, 2400)}`);
+ }
+ return lines.join('\n');
+ }).join('\n');
+}
+
+function shouldUseCodexAppContinuationRetry(entry) {
+ return (entry?.agent || '') === 'codexapp' && !!(entry.turnId || hasRuntimeOutput(entry));
+}
+
+function buildCodexAppContinuationRetryText(entry, retryRequest, rawError) {
+ const original = retryPreviewValue(
+ retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.originalText || retryRequest.text || '',
+ 5000,
+ );
+ const partialText = retryPreviewValue(entry.fullText || '', 7000);
+ const toolSummary = buildCodexRetryToolSummary(entry.toolCalls || []);
+ const errorText = retryPreviewValue(rawError || entry.lastError || '', 1200);
+ const parts = [
+ '继续上一轮被临时服务或网络错误中断的 Codex App 任务。',
+ '不要从头重做,不要重复已经完成的命令、工具调用或文件修改;请基于现有线程上下文和下面 cc-web 已观察到的中断前状态继续执行。不要在回复中复述这段内部重试说明。',
+ ];
+ if (original) {
+ parts.push(`原始用户请求(仅用于理解目标,不要当成新请求从头执行):\n${original}`);
+ }
+ if (partialText) {
+ parts.push(`cc-web 已观察到的中断前助手输出(尾部):\n${partialText}`);
+ }
+ if (toolSummary) {
+ parts.push(`cc-web 已观察到的工具/执行摘要:\n${toolSummary}`);
+ }
+ if (errorText) {
+ parts.push(`中断原因:\n${errorText}`);
+ }
+ parts.push('请从中断处继续完成剩余工作。');
+ return parts.filter(Boolean).join('\n\n');
+}
+
function getCodexRetryConfig() {
return normalizeCodexRetryConfig(loadCodexConfig().retry);
}
@@ -4723,7 +4794,7 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
cancelCodexCapacityRetry(sessionId);
return false;
}
- const attempts = (previous?.attempts || 0) + 1;
+ const attempts = (previous?.attempts || entry.codexRetry?.attempt || 0) + 1;
if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) {
cancelCodexCapacityRetry(sessionId);
return false;
@@ -4731,14 +4802,32 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
const delayMs = codexTransientRetryDelayMs(retryConfig);
if (previous?.timer) clearTimeout(previous.timer);
+ const expectedThreadId = retryRequest.expectedThreadId
+ || entry.expectedThreadId
+ || entry.codexRetry?.expectedThreadId
+ || entry.threadId
+ || previous?.expectedThreadId
+ || null;
+ const originalText = retryRequest.originalText || retryRequest.text || retryRequest.runtimeText || '';
+ const originalRuntimeText = retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.text || '';
+ const useContinuationRetry = shouldUseCodexAppContinuationRetry(entry);
+ const continuationText = useContinuationRetry
+ ? buildCodexAppContinuationRetryText(entry, retryRequest, rawError)
+ : '';
+ const retryRuntimeText = continuationText || originalRuntimeText || originalText;
+ const retryText = retryRuntimeText || originalText;
const retry = {
- text: retryRequest.text || retryRequest.runtimeText || '',
- runtimeText: retryRequest.runtimeText || retryRequest.text || '',
+ text: retryText,
+ runtimeText: retryRuntimeText,
+ originalText,
+ originalRuntimeText,
mode: retryRequest.mode || 'yolo',
agent: retryRequest.agent || entry.agent || 'codex',
- attachments: Array.isArray(retryRequest.attachments) ? retryRequest.attachments : [],
+ attachments: useContinuationRetry ? [] : (Array.isArray(retryRequest.attachments) ? retryRequest.attachments : []),
mcpContext: retryRequest.mcpContext || {},
+ expectedThreadId,
+ useContinuationRetry,
attempts,
retryMode: retryConfig.mode,
timer: null,
@@ -4756,17 +4845,22 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
return;
}
if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) {
+ pendingCodexCapacityRetries.delete(sessionId);
plog('WARN', 'codex_capacity_retry_skipped_busy', {
sessionId: sessionId.slice(0, 8),
attempt: latest.attempts,
});
+ if (latest.ws && latest.ws.readyState === 1) sendSessionList(latest.ws);
return;
}
+ pendingCodexCapacityRetries.delete(sessionId);
const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null;
plog('INFO', 'codex_capacity_retry_start', {
sessionId: sessionId.slice(0, 8),
attempt: latest.attempts,
+ expectedThreadId: latest.expectedThreadId ? String(latest.expectedThreadId).slice(0, 24) : null,
+ continuation: !!latest.useContinuationRetry,
});
handleMessage(ws, {
type: 'message',
@@ -4779,6 +4873,17 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
hideInHistory: true,
runtimeText: latest.runtimeText,
mcpContext: latest.mcpContext,
+ codexRetry: latest.agent === 'codexapp'
+ ? {
+ isAutoRetry: true,
+ attempt: latest.attempts,
+ retryMode: latest.retryMode,
+ expectedThreadId: latest.expectedThreadId || null,
+ originalText: latest.originalText || latest.text || '',
+ originalRuntimeText: latest.originalRuntimeText || latest.runtimeText || '',
+ useContinuationRetry: !!latest.useContinuationRetry,
+ }
+ : null,
skipPendingCrossConversationFlush: true,
});
}, delayMs);
@@ -4790,16 +4895,19 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null,
retryMode: retryConfig.mode,
delayMs,
+ expectedThreadId: expectedThreadId ? String(expectedThreadId).slice(0, 24) : null,
+ continuation: useContinuationRetry,
error: String(rawError || '').slice(0, 300),
});
if (entry.ws) {
const attemptText = retryConfig.mode === 'forever'
? `第 ${attempts} 次`
: `第 ${attempts}/${retryConfig.maxAttempts} 次`;
+ const continuationText = useContinuationRetry ? ',将从中断处继续' : '';
wsSend(entry.ws, {
type: 'system_message',
sessionId,
- message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText})。`,
+ message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText}${continuationText})。`,
});
}
return true;
@@ -6869,6 +6977,7 @@ function handleMessage(ws, msg, options = {}) {
return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, {
mcpContext: options.mcpContext || {},
crossConversation: options.crossConversation || null,
+ codexRetry: options.codexRetry || null,
});
}
@@ -8236,6 +8345,19 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment
return { ok: false, code: 'empty_message', message: '消息内容不能为空。' };
}
+ const codexRetry = options.codexRetry && typeof options.codexRetry === 'object'
+ ? {
+ isAutoRetry: !!options.codexRetry.isAutoRetry,
+ attempt: Number.isFinite(Number(options.codexRetry.attempt)) ? Number(options.codexRetry.attempt) : null,
+ retryMode: options.codexRetry.retryMode || null,
+ expectedThreadId: options.codexRetry.expectedThreadId || null,
+ originalText: options.codexRetry.originalText || null,
+ originalRuntimeText: options.codexRetry.originalRuntimeText || null,
+ useContinuationRetry: !!options.codexRetry.useContinuationRetry,
+ }
+ : null;
+ const currentThreadId = getRuntimeSessionId(session);
+ const expectedThreadId = codexRetry?.expectedThreadId || currentThreadId || null;
const retryAttachments = resolvedAttachments.map((attachment) => ({
id: attachment.id,
kind: 'image',
@@ -8250,24 +8372,30 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment
ws,
agent: 'codexapp',
cwd: session.cwd || getDefaultSessionCwd(),
- threadId: getRuntimeSessionId(session),
+ threadId: expectedThreadId,
+ expectedThreadId,
turnId: null,
fullText: '',
toolCalls: [],
toolOutputDeltas: new Map(),
agentMessageItems: new Map(),
mcpContext: options.mcpContext || {},
+ codexRetry,
lastUsage: null,
lastError: null,
errorSent: false,
crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
retryRequest: {
- text: runtimeTextValue,
- runtimeText: runtimeTextValue,
+ text: codexRetry?.originalText || runtimeTextValue,
+ runtimeText: codexRetry?.originalRuntimeText || runtimeTextValue,
+ originalText: codexRetry?.originalText || runtimeTextValue,
+ originalRuntimeText: codexRetry?.originalRuntimeText || runtimeTextValue,
+ lastRetryText: runtimeTextValue,
mode: session.permissionMode || 'yolo',
agent: 'codexapp',
attachments: retryAttachments,
mcpContext: options.mcpContext || {},
+ expectedThreadId,
},
clientUserMessageId: crypto.randomUUID(),
startedAt: new Date().toISOString(),
@@ -8293,11 +8421,33 @@ async function startCodexAppTurn(sessionId, input) {
const client = clientResult.client;
await client.start();
- let threadId = getRuntimeSessionId(session);
+ const currentThreadId = getRuntimeSessionId(session);
+ const expectedThreadId = entry.expectedThreadId
+ || entry.codexRetry?.expectedThreadId
+ || entry.retryRequest?.expectedThreadId
+ || entry.threadId
+ || currentThreadId
+ || null;
+ let threadId = expectedThreadId || currentThreadId;
const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} });
if (threadId) {
- const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
- threadId = resumed?.thread?.id || threadId;
+ const requestedThreadId = threadId;
+ const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000);
+ const resumedThreadId = resumed?.thread?.id || requestedThreadId;
+ if (expectedThreadId && resumedThreadId !== expectedThreadId) {
+ const expectedShort = String(expectedThreadId).slice(0, 24);
+ const actualShort = String(resumedThreadId).slice(0, 24);
+ plog('WARN', 'codex_app_thread_resume_mismatch', {
+ sessionId: sessionId.slice(0, 8),
+ expectedThreadId: expectedShort,
+ actualThreadId: actualShort,
+ autoRetry: !!entry.codexRetry?.isAutoRetry,
+ retryAttempt: entry.codexRetry?.attempt || null,
+ });
+ const prefix = entry.codexRetry?.isAutoRetry ? 'Codex App 自动重试' : 'Codex App';
+ throw new Error(`${prefix}恢复到不同线程,已停止以避免上下文丢失(期望 ${expectedShort},实际 ${actualShort})。`);
+ }
+ threadId = resumedThreadId;
} else {
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
threadId = started?.thread?.id || null;