feat: complete Codex App capabilities and rebuild release

This commit is contained in:
shiyue
2026-08-20 13:37:23 +08:00
parent d7dc32e924
commit 19c1601351
8 changed files with 756 additions and 54 deletions

View File

@@ -0,0 +1,53 @@
# Codex App Web Search 接入发现
## 已确认
- 用户观察正确Codex App/app-server 本身会做原生自动上下文压缩。
- 旧的“检测上下文超限 → `/compact` → 重放”属于 detached Claude/旧 Codex CLI 的客户端兜底,不等于 Codex App 没有自动压缩。
- `lib/codex-app-runtime.js` 已识别 `webSearch` item并映射为 `web_search` / `WebSearch` 展示事件。
- 当前 Codex 配置把 `enableSearch``supportsSearch` 硬编码为 `false`,保存开启请求时也会忽略;该限制来自旧 `codex exec` 路径,需要收敛到 Codex App 原生能力。
- OpenAI 官方页面在当前网络环境不可访问developers 域返回 Forbiddenplatform 域被 Cloudflare 拦截。
- 按用户提示补用 `proxyd.picpi.top/{原始 URL}` 后,官方搜索页与 app-server 文档页仍返回上游 `Forbidden`;代理链路可达,但无法绕过官方站点的访问限制。
- TODO CSV 计划审查已通过;审查员建议在确认 schema 后记录字段名、关闭语义与证据来源。
## 待确认
- hapi 是否使用相同字段;主线程全文检索暂未命中,等待只读研究子代理复核。
## Codex 0.147.0 协议证据
- 本机 `codex app-server generate-json-schema --experimental` 生成的 v2 schema 中,`Config.web_search` 引用 `WebSearchMode`
- `WebSearchMode` 的枚举值为 `disabled``cached``indexed``live`
- `ThreadStartParams.config``ThreadResumeParams.config` 都允许线程级 config`TurnStartParams` 没有 Web Search 专用字段。
- cc-web 的 `codexAppThreadParams()` 已把 `codexAppThreadConfig()` 同时用于 `thread/start``thread/resume`,因此正确接入点是 `codexAppThreadConfig()` 写入 `web_search`
- 生成的 resolved `Config` 同时存在两层:顶层 `web_search: WebSearchMode`,以及 `tools.web_search: WebSearchToolConfig`。前者选择 `disabled/cached/indexed/live` 模式,后者承载 `context_size/allowed_domains/location` 等工具配置;不能把二者混成同一字段。
- CLI 解析实测:`web_search="live"``tools.web_search=true``tools.web_search=false` 都能加载;`tools.web_search="live"``"disabled"` 不合法。
- app-server `config/read` 实测:顶层配置会解析为 `web_search: "live"``tools.web_search=true/false` 都归一到 `tools.web_search: null`,仅凭读取结果无法证明布尔输入的最终启停语义。
- `codex --help` 明确 `--search` 是“Enable live web search”但该开关只属于 Codex CLI 根命令,`codex app-server --search` 会被拒绝app-server 应使用线程 config。
- 官方 OpenAI 文档通过 `proxyd-accelerator` 成功获取:`web-search.md` 明确 live 用 `web_search = "live"`、关闭用 `web_search = "disabled"``config-reference.md` 明确 `tools.web_search` 只是可选的上下文大小、域名和位置配置。
- 最终协议决策cc-web 开关只传顶层 `web_search: enableSearch ? 'live' : 'disabled'`;当前产品没有域名/位置细节 UI因此不传 `tools.web_search`
- app-server 的 `webSearch` thread item 已由 `lib/codex-app-runtime.js` 映射为前端 `web_search` 工具事件,展示链路已有基础。
## 实现与回归落点
- 服务端配置需把 `load/save/masked/handleSave``enableSearch` 改为真实布尔值,并将 `supportsSearch` 对 Codex App 暴露为 `true`;旧 Codex CLI 路径仍不读取该开关。
- `codexAppThreadConfig()` 应无条件写入 `web_search: enableSearch ? 'live' : 'disabled'`,让 `thread/start``thread/resume` 都明确覆盖用户全局搜索默认值。
- 设置面板已有 `.settings-toggle-row` / `.settings-switch` 组件可新增“Web Search”开关无需新增 CSS。
- mock app-server 当前可在 `thread/start` / `thread/resume` 捕获 `params.config.web_search`;回归应分别覆盖开启后的 `live` 与保存关闭后的 `disabled`
- 现有配置回归仍断言“unsupported/ignore”必须改为 `supportsSearch === true``enableSearch === true`,并在后续关闭保存中断言 `enableSearch === false`
- Web Search 事件映射已有实现,但目前回归未直接断言 `webSearch` item → `web_search` 工具事件,需要补 mock 事件或静态契约断言。
## 最终实现结果
- `server.js` 的 Codex 配置现在真实保存/读取 `enableSearch`,对 Codex App 暴露 `supportsSearch: true`保存提示不再声称“Codex exec 暂未接入”。
- `codexAppThreadConfig()` 每次组装线程参数时写入 `web_search: 'live' | 'disabled'`;同一配置同时覆盖 `thread/start``thread/resume`
- `public/app.js` 设置面板新增可读写的 Web Search switch说明文案限定为当前 Codex App 会话。
- mock app-server 暴露首次线程搜索模式与当前线程搜索模式,回归分别证明开启态 `live`、关闭后的 resume `disabled`,并证明未使用 `tools.web_search` 代替模式开关。
- `scripts/regression.js` 新增 `webSearch` item 的 `WebSearch/web_search` tool_start/tool_end/persistence 断言。
- 两次回归均通过:`timeout 60s node scripts/regression.js``timeout 60s npm run regression`
## 旧 Codex 模式盘点结论
- 普通 UI 已默认 Codex AppCodex App 的 `/compact``/goal`、协作模式、MCP、附件、标题、自动容量重试已有实现。
- 仍需后续单独处理的遗留:后端可新建 `agent='codex'`,旧 `codex exec` 运行链路完整存在rollout 导入与 regression 仍覆盖旧 CLI。它们属于迁移/封口任务,不在本轮 Web Search 范围内。
- 未知 app-server request 的“不支持”兜底应保留;运行中已知 slash 的策略需要产品决定,不应与本轮 Web Search 混改。

View File

@@ -0,0 +1,18 @@
# Codex App Web Search 接入进度
- 2026-08-20T10:15:00+08:00读取 `openai-docs``planning-with-files``todo-list-csv` 技能。
- 2026-08-20T10:15:00+08:00官方页面访问失败记录降级原因。
- 2026-08-20T10:15:00+08:00确认 codebase-memory 项目 `home-cc-web` 索引状态为 ready。
- 2026-08-20T10:15:00+08:00确认工作区存在 Goal 生命周期并行改动,本轮必须增量编辑。
- 2026-08-20T10:15:00+08:00建立独立 scoped plan未覆盖根目录 hooks 验证计划。
- 2026-08-20T10:20:00+08:00计划审查子代理通过无阻塞问题。
- 2026-08-20T10:20:00+08:00使用用户点名的 `proxyd-accelerator` 重试官方页面,仍收到上游 Forbidden继续以安装版 app-server schema 为直接证据。
- 2026-08-20T10:25:00+08:00由 Codex CLI 0.147.0 生成 experimental v2 schema确认线程 config 字段为 `web_search`,枚举包含 `disabled/cached/indexed/live``thread/start``thread/resume` 均支持 config。
- 2026-08-20T10:30:00+08:00完成配置、设置 UI、mock 和 regression 落点梳理;确认可复用现有 settings switch 样式,且 Goal 生命周期改动与目标区域可增量合并。
- 2026-08-20T10:40:00+08:00发现 `Config.web_search``Config.tools.web_search` 双层语义,暂停实现代理锁定字段;已启动追加复核,避免把合法配置误当成实际可用搜索。
- 2026-08-20T10:45:00+08:00通过 `proxyd-accelerator` 成功读取 OpenAI 官方 Markdown 文档,确认 live/disabled 应使用顶层 `web_search`;通知实现代理继续。
- 2026-08-20T10:45:00+08:00只读旧模式盘点完成除本轮 Web Search 外,主要遗留是后端仍可新建旧 `agent=codex`、完整 `codex exec` 路径和测试夹具保护Codex App 原生 compact/Goal/协作/MCP/附件/重试已完成。
- 2026-08-20T11:00:00+08:00完成 Web Search 配置持久化、Codex App 线程级 `web_search` 注入、设置开关、mock 观测字段和回归断言;保留旧 exec 路径不启用搜索。
- 2026-08-20T11:00:00+08:00修正一次回归断言时机live 断言放在开启态disabled 断言放在配置关闭后的 thread/resume。
- 2026-08-20T11:00:00+08:00`timeout 60s node scripts/regression.js``timeout 60s npm run regression` 均通过;四个 JS 文件语法检查、`git diff --check` 均通过。
- 2026-08-20T11:00:00+08:00确认 Codex App 原生自动 compact 已存在,本轮未新增客户端“最终超限后 compact+重放”逻辑。

View File

@@ -0,0 +1,37 @@
# Codex App Web Search 接入计划
## 目标
让设置中的 `enableSearch` 驱动 Codex App/app-server 原生 Web Search保持旧 `codex exec` 路径禁用搜索,并用协议回归证明参数形状、关闭语义和事件展示均正确。
## 验收标准
- 开启 Web Search 后Codex App 的官方协议字段收到明确启用值。
- 关闭 Web Search 后,协议字段为禁用值或按官方语义省略,不影响普通 turn。
- 设置保存与读取不再把 Codex App 搜索能力硬编码为不可用。
- `webSearch` item 继续按 `web_search` 工具事件展示。
- 不新增“上下文超限后客户端 compact + 重放”逻辑;文档结论明确区分上游原生自动压缩与 cc-web 额外失败兜底。
- 回归、语法检查和差异检查通过,且不覆盖 Goal 生命周期的并行改动。
## 阶段
1. [完成] 确认 Codex App Web Search 官方协议与现有链路
2. [完成] 编写 Web Search 配置与协议回归断言
3. [完成] 接入 Codex App 原生 Web Search 参数
4. [完成] 更新设置界面与配置保存语义
5. [完成] 验证 Web Search 事件展示与自动 compact 现状
6. [完成] 运行回归、语法与差异检查
7. [完成] 汇总兼容性结论并清理跟踪文件
## 决策
- 以本机 Codex app-server 生成 schema 为参数形状的直接证据,并用 hapi 实现交叉核对。
- Web Search 只接 Codex App 原生协议,不重新启用旧 `codex exec --search` 路径。
- 原生自动 compact 已存在;客户端“最终超限后 compact 并重放”不是本轮实现目标。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---:|---|
| `developers.openai.com` 返回 Forbidden | 1 | 改查同为官方域名的 `platform.openai.com` |
| `platform.openai.com` 被 Cloudflare 拦截 | 2 | 按 `openai-docs` 降级,读取官方文档参考并转用本机协议 schema 交叉验证 |

View File

@@ -587,13 +587,14 @@
function updateGenerationControls() {
const noteActive = !!noteMode;
const runtimeBusy = isGenerating || currentSessionRunning;
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !noteActive;
const sendLabel = noteActive ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
if (sendBtn) {
sendBtn.classList.toggle('note-send', noteActive);
sendBtn.title = sendLabel;
sendBtn.setAttribute('aria-label', sendLabel);
sendBtn.hidden = isGenerating ? !(noteActive || allowRuntimeInsert) : false;
sendBtn.hidden = runtimeBusy ? !(noteActive || allowRuntimeInsert) : false;
}
if (queueSendBtn) {
const queueAvailable = noteActive && supportsQueuedSend();
@@ -604,7 +605,7 @@
queueSendBtn.setAttribute('aria-label', queueLabel);
}
if (abortBtn) {
abortBtn.hidden = !isGenerating;
abortBtn.hidden = !runtimeBusy;
}
}
@@ -5829,6 +5830,7 @@
}
}
updateCwdBadge();
updateGenerationControls();
if (!running) scheduleQueuedMessageDrain();
}
@@ -7311,11 +7313,11 @@
case 'done':
if (!isCurrentSessionEvent(msg)) {
if (msg.sessionId) {
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = false; });
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = msg.goalActive === true; });
}
break;
}
finishGenerating(msg.sessionId);
finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true });
break;
case 'system_message':
@@ -7610,8 +7612,9 @@
return true;
}
function finishGenerating(sessionId) {
function finishGenerating(sessionId, options = {}) {
if (sessionId && currentSessionId && sessionId !== currentSessionId) return;
const keepRunning = options.keepRunning === true;
const hasPersistedAssistantMessage = !!(
pendingText
|| (Array.isArray(window.pendingContentBlocks) && window.pendingContentBlocks.length > 0)
@@ -7619,7 +7622,7 @@
isGenerating = false;
generatingSessionId = null;
updateNoteModeUI();
setCurrentSessionRunningState(false);
setCurrentSessionRunningState(keepRunning);
msgInput.focus();
if (pendingText || (window.pendingContentBlocks && window.pendingContentBlocks.length > 0)) {
@@ -11207,6 +11210,7 @@
const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent);
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
if (currentSessionRunning && !isGenerating) return;
if (isGenerating && !runtimeInsert) return;
hideCmdMenu();
hideOptionPicker();
@@ -12031,6 +12035,19 @@
</div>
<div id="codex-profile-area"></div>
<label class="settings-toggle-row">
<span class="settings-toggle-copy">
<span class="settings-toggle-title">Web Search</span>
<span class="settings-toggle-meta">仅作用于当前 Codex App 会话的原生联网搜索。</span>
</span>
<span class="settings-switch">
<input type="checkbox" id="codex-enable-search">
<span class="settings-switch-track" aria-hidden="true">
<span class="settings-switch-thumb"></span>
</span>
</span>
</label>
<div class="settings-divider"></div>
<div class="settings-section-title">容量失败重试</div>
<div class="settings-field">
@@ -12087,6 +12104,7 @@
const closeBtn = panel.querySelector('.settings-close');
const codexModeSelect = panel.querySelector('#codex-mode');
const codexProfileArea = panel.querySelector('#codex-profile-area');
const codexSearchToggle = panel.querySelector('#codex-enable-search');
const codexRetryModeSelect = panel.querySelector('#codex-retry-mode');
const codexRetryIntervalInput = panel.querySelector('#codex-retry-interval');
const codexRetryAttemptsInput = panel.querySelector('#codex-retry-attempts');
@@ -12302,6 +12320,8 @@
codexModeSelect.value = currentCodexConfig.mode || 'local';
codexEditingProfiles = (currentCodexConfig.profiles || []).map((profile) => ({ ...profile }));
codexActiveProfile = currentCodexConfig.activeProfile || (codexEditingProfiles[0]?.name || '');
codexSearchToggle.checked = !!currentCodexConfig.enableSearch;
codexSearchToggle.disabled = currentCodexConfig.supportsSearch === false;
setCodexRetryConfig(currentCodexConfig.retry || codexRetryConfig);
renderCodexProfileArea();
};
@@ -12319,7 +12339,7 @@
mode: codexModeSelect.value,
activeProfile: codexActiveProfile,
profiles: codexEditingProfiles,
enableSearch: false,
enableSearch: !!codexSearchToggle.checked,
retry: readCodexRetryConfig(),
};
send({ type: 'save_codex_config', config });

View File

@@ -102,6 +102,7 @@ function ensureThread(threadId, params = {}) {
capacityRetryAttempts: new Map(),
reconnectRetryAttempts: new Map(),
goal: null,
threadStartWebSearchMode: params.config?.web_search || null,
});
}
const thread = threads.get(id);
@@ -537,9 +538,12 @@ function completeTurnWithoutTerminalNotification(thread, turnId, text) {
thread.steers = [];
}
function completeGoalBackgroundTurn(thread, objective) {
function completeGoalBackgroundTurn(thread, objective, turnNumber = 1, totalTurns = 1) {
const turnId = `goal-turn-${crypto.randomUUID()}`;
const text = `Goal background output: ${objective}`;
const itemId = `goal-agent-msg-${turnNumber}`;
const text = turnNumber === 1
? `Goal background output: ${objective}`
: `Goal continuation output ${turnNumber}: ${objective}`;
thread.activeTurnId = turnId;
send({
method: 'turn/started',
@@ -553,7 +557,7 @@ function completeGoalBackgroundTurn(thread, objective) {
params: {
threadId: thread.id,
turnId,
itemId: 'goal-agent-msg',
itemId,
delta: text,
},
});
@@ -564,13 +568,24 @@ function completeGoalBackgroundTurn(thread, objective) {
turnId,
completedAtMs: Date.now(),
item: {
id: 'goal-agent-msg',
id: itemId,
type: 'agentMessage',
text,
status: 'completed',
},
},
});
if (turnNumber >= totalTurns && thread.goal) {
thread.goal = {
...thread.goal,
status: 'complete',
updatedAt: Date.now(),
};
send({
method: 'thread/goal/updated',
params: { threadId: thread.id, goal: thread.goal },
});
}
send({
method: 'thread/tokenUsage/updated',
params: {
@@ -591,6 +606,9 @@ function completeGoalBackgroundTurn(thread, objective) {
},
});
thread.activeTurnId = null;
if (turnNumber < totalTurns) {
setTimeout(() => completeGoalBackgroundTurn(thread, objective, turnNumber + 1, totalTurns), 120);
}
}
function requestClient(method, params, callback) {
@@ -667,6 +685,9 @@ function completeMcpToolTurn(thread, turnId) {
ok: true,
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || urlSourceSessionId,
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || urlSourceHopCount,
threadStartWebSearchMode: thread.threadStartWebSearchMode || null,
threadConfigMethod: thread.lastThreadConfigMethod || null,
webSearchMode: thread.config?.web_search || null,
hasCcwebMcpConfig: Boolean(ccwebConfig),
hasProjectMcpConfig: Boolean(projectConfig),
ccwebType: ccwebConfig?.type || (ccwebConfig?.url ? 'streamable_http' : (ccwebConfig?.command ? 'stdio' : null)),
@@ -1092,19 +1113,42 @@ function handleRequest(message) {
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
thread.lastThreadConfigMethod = 'thread/start';
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;
}
if (method === 'thread/resume') {
if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
const thread = ensureThread(null, params);
thread.lastThreadConfigMethod = 'thread/start';
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);
thread.lastThreadConfigMethod = 'thread/resume';
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;
}
if (method === 'thread/compact/start') {
const thread = ensureThread(params.threadId, params);
const compactTurnId = `app-compact-${crypto.randomUUID()}`;
thread.lastCompactionTurnId = compactTurnId;
send({
method: 'thread/compacted',
params: {
threadId: thread.id,
turnId: compactTurnId,
},
});
send({
id,
result: {
threadId: thread.id,
turnId: compactTurnId,
},
});
return;
}
if (method === 'thread/goal/get') {
const thread = ensureThread(params.threadId, params);
send({ id, result: { goal: thread.goal } });
@@ -1130,10 +1174,11 @@ function handleRequest(message) {
params: { threadId: thread.id, goal: thread.goal },
});
setTimeout(() => {
send({ id, result: { goal: thread.goal } });
if (params.objective) {
setTimeout(() => completeGoalBackgroundTurn(thread, objective), 50);
}
send({ id, result: { goal: thread.goal } });
if (params.objective) {
const totalTurns = /improve benchmark coverage/i.test(objective) ? 2 : 1;
setTimeout(() => completeGoalBackgroundTurn(thread, objective, 1, totalTurns), 50);
}
}, 250);
return;
}

View File

@@ -2444,6 +2444,47 @@ function assertCodexAppRuntimeSubAgentActivityContract() {
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
const webSearchSent = [];
const webSearchRuntime = createCodexAppRuntime({
wsSend: (_ws, payload) => webSearchSent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
const webSearchEntry = { ws: {}, toolCalls: [], fullText: '' };
webSearchRuntime.processCodexAppNotification(webSearchEntry, {
method: 'item/started',
params: {
item: {
id: 'runtime-web-search',
type: 'webSearch',
query: 'cc-web Web Search regression',
},
},
}, sessionId);
const webSearchStart = webSearchSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-web-search');
assert(webSearchStart, 'Runtime webSearch item/started should emit tool_start');
assert(webSearchStart.name === 'WebSearch', 'Runtime webSearch tool_start should map to WebSearch');
assert(webSearchStart.kind === 'web_search', 'Runtime webSearch tool_start should map to web_search kind');
assert(webSearchStart.input?.type === 'webSearch', 'Runtime webSearch input should preserve original item type');
assert(webSearchStart.input?.query === 'cc-web Web Search regression', 'Runtime webSearch input should preserve query');
webSearchRuntime.processCodexAppNotification(webSearchEntry, {
method: 'item/completed',
params: {
item: {
id: 'runtime-web-search',
type: 'webSearch',
query: 'cc-web Web Search regression',
results: [{ title: 'Result', url: 'https://example.com/result' }],
},
},
}, sessionId);
const webSearchEnd = webSearchSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-web-search');
assert(webSearchEnd, 'Runtime webSearch item/completed should emit tool_end');
assert(webSearchEnd.kind === 'web_search', 'Runtime webSearch tool_end should keep web_search kind');
assert(webSearchEntry.toolCalls[0]?.name === 'WebSearch', 'Runtime persisted webSearch tool should use WebSearch name');
assert(webSearchEntry.toolCalls[0]?.kind === 'web_search', 'Runtime persisted webSearch tool should use web_search kind');
}
function assertCodexAppTransientReconnectContract() {
@@ -2531,6 +2572,10 @@ function assertFrontendPrimaryCodexAppUiContract() {
assert(source.includes("localStorage.setItem('cc-web-agent', currentAgent);"), 'Frontend should overwrite stale cc-web-agent storage with the primary UI agent');
assert(source.includes('currentAgent = normalizeUiAgent(agent);'), 'setCurrentAgent should coerce ordinary UI agent changes back to Codex App');
assert(source.includes('return sessions.filter((s) => isPrimaryUiAgent(s.agent));'), 'Session list should only expose Codex App sessions in ordinary UI');
assert(source.includes('id="codex-enable-search"'), 'Codex settings should expose the Web Search toggle');
assert(source.includes('仅作用于当前 Codex App 会话的原生联网搜索'), 'Codex Web Search setting copy should scope the toggle to Codex App native search');
assert(source.includes('codexSearchToggle.checked = !!currentCodexConfig.enableSearch;'), 'Codex Web Search toggle should load the persisted setting');
assert(source.includes('enableSearch: !!codexSearchToggle.checked'), 'Codex Web Search save should send the real toggle state');
assert(
/function applySessionSnapshot\(snapshot[\s\S]*?!isPrimaryUiAgent\(snapshotAgent\)[\s\S]*?return false;[\s\S]*?return true;/.test(source),
'Frontend should reject legacy Claude/Codex snapshots from the current view'
@@ -4001,6 +4046,76 @@ function assertCodexAppStaleRunningRecoveryContract() {
);
}
function assertCodexAppGoalLifecycleContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const doneStart = frontendSource.indexOf("case 'done':");
const doneEnd = frontendSource.indexOf("case 'system_message':", doneStart);
const doneBlock = doneStart >= 0 && doneEnd > doneStart
? frontendSource.slice(doneStart, doneEnd)
: '';
const finishBlock = extractFunctionSource(frontendSource, 'finishGenerating');
const controlsBlock = extractFunctionSource(frontendSource, 'updateGenerationControls');
const sendMessageBlock = extractFunctionSource(frontendSource, 'sendMessage');
const runningBlock = extractFunctionSource(serverSource, 'isSessionRunning');
const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppGoalNotification');
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
const pauseBlock = extractFunctionSource(serverSource, 'pauseCodexAppGoalForAbort');
const abortBlock = extractFunctionSource(serverSource, 'handleAbort');
const messageBlock = extractFunctionSource(serverSource, 'handleMessage');
const deleteBlock = extractFunctionSource(serverSource, 'handleDeleteSession');
assert(
doneBlock.includes('snapshot.isRunning = msg.goalActive === true')
&& doneBlock.includes('finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true })'),
'Frontend done handling should keep current and background Goal sessions running between turns'
);
assert(
finishBlock.includes('options = {}')
&& finishBlock.includes('const keepRunning = options.keepRunning === true')
&& finishBlock.includes('setCurrentSessionRunningState(keepRunning)'),
'Frontend finishGenerating should close one bubble without ending an active Goal'
);
assert(
controlsBlock.includes('const runtimeBusy = isGenerating || currentSessionRunning')
&& controlsBlock.includes('abortBtn.hidden = !runtimeBusy'),
'Frontend should keep Stop available while an active Goal is between turns'
);
assert(
sendMessageBlock.includes('if (currentSessionRunning && !isGenerating) return;'),
'Frontend should not submit an ordinary message into an active Goal turn gap'
);
assert(
runningBlock.includes('isCodexAppGoalActive(sessionId)')
&& notificationBlock.includes("method !== 'thread/goal/updated'")
&& notificationBlock.includes("method !== 'thread/goal/cleared'")
&& notificationBlock.includes('updateCodexAppGoalState(matched.session, goal)'),
'Server should maintain thread-level Goal state independently from active turns'
);
assert(
completeBlock.includes('const goalActive = isCodexAppGoalActive(sessionId)')
&& completeBlock.includes("{ type: 'done', sessionId, costUsd: null, goalActive }")
&& completeBlock.includes('if (goalActive)')
&& completeBlock.includes('broadcastSessionList()'),
'Server turn completion should report active Goal state without sending final background completion'
);
assert(
pauseBlock.includes("status: 'paused'")
&& pauseBlock.includes("client.request('thread/goal/set'")
&& abortBlock.includes('pauseCodexAppGoalForAbort(sessionId, ws)')
&& abortBlock.includes('handleCodexAppAbortSession(sessionId, ws)'),
'Stop should pause the Goal and interrupt any currently active turn'
);
assert(
messageBlock.includes('!codexAppGoalStates.has(sessionId)')
&& messageBlock.includes('loadSession(sessionId)')
&& messageBlock.includes('isCodexAppGoalActive(sessionId)')
&& messageBlock.includes('Goal 仍在持续运行')
&& deleteBlock.includes('codexAppGoalStates.delete(sessionId)'),
'Server should block ordinary Goal-gap turns and clean Goal state on deletion'
);
}
function assertRuntimeImageSendStaticContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
@@ -5954,6 +6069,11 @@ async function main() {
console.log('Codex App stale running regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-goal-lifecycle') {
assertCodexAppGoalLifecycleContract();
console.log('Codex App Goal lifecycle regression checks passed.');
return;
}
if (regressionTarget === 'session-switch-race') {
assertSessionSwitchRaceContract();
console.log('Session switch race regression checks passed.');
@@ -6064,6 +6184,7 @@ async function main() {
assertCcwebMcpChildUpdateCoalescingContract();
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
assertCodexAppGoalLifecycleContract();
assertSessionSwitchRaceContract();
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
@@ -6260,8 +6381,8 @@ async function main() {
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
assert(codexConfigMsg.config.supportsSearch === true, 'Codex config should expose Codex App native search capability');
assert(codexConfigMsg.config.enableSearch === true, 'Codex config should persist enabled Web Search');
assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip');
assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip');
assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip');
@@ -7215,6 +7336,13 @@ async function main() {
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic web search enabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppEnabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(/"threadStartWebSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread/start should pass web_search=live when Web Search is enabled');
assert(/"webSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread config should keep web_search=live while Web Search is enabled');
assert(!/"webSearchToolConfig"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App should use top-level web_search instead of tools.web_search as the mode switch');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
@@ -7229,6 +7357,7 @@ async function main() {
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
);
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
assert(codexAppChangedConfig.config.enableSearch === false, 'Codex App config-change regression should persist disabled Web Search');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
@@ -7239,6 +7368,13 @@ async function main() {
assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic after web search disabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDisabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(/"threadConfigMethod": "thread\/resume"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App existing thread should refresh config through thread/resume');
assert(/"webSearchMode": "disabled"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App thread/resume should pass web_search=disabled after Web Search is disabled');
assert(!/"webSearchToolConfig"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App should not use tools.web_search as the Web Search mode switch');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const codexAppRetryText = 'codexapp capacity retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
@@ -7346,13 +7482,45 @@ async function main() {
/Goal background output: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000);
const codexAppGoalFirstTurnDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'done' &&
msg.sessionId === codexAppSession.sessionId &&
msg.goalActive === true
), 5000);
assert(codexAppGoalFirstTurnDone.goalActive === true, 'Goal first turn completion should remain explicitly active');
const codexAppGoalStillRunningList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
), 5000);
assert(codexAppGoalStillRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Goal should stay running between continuation turns');
const codexAppGoalContinuationDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/Goal continuation output 2: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal continuation output 2/.test(codexAppGoalContinuationDelta.text || ''), 'Goal continuation turn should stream through the same session');
const codexAppGoalFinalDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'done' &&
msg.sessionId === codexAppSession.sessionId &&
msg.goalActive !== true
), 5000);
assert(codexAppGoalFinalDone.goalActive !== true, 'Goal terminal turn should emit a final done event');
const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
), 5000);
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App Goal should become idle only after the Goal reaches a terminal status');
const storedCodexAppGoalSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(
storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal background output: improve benchmark coverage/.test(String(message.content || ''))),
'Goal first turn assistant output should be persisted'
);
assert(
storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal continuation output 2: improve benchmark coverage/.test(String(message.content || ''))),
'Goal continuation assistant output should be persisted separately'
);
ws.send(JSON.stringify({
type: 'message',
text: '/goal improve benchmark coverage',
@@ -7372,7 +7540,7 @@ async function main() {
assert(!messages.some((msg) => msg.type === 'session_message' && msg.message?.id === codexAppGoalMessageId), 'Duplicate Goal command ids should not append another user bubble');
assert(!messages.some((msg) => msg.type === 'text_delta' && /Goal background output: improve benchmark coverage/.test(msg.text || '')), 'Duplicate Goal command ids should not trigger another Goal RPC background turn');
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal complete/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
@@ -7432,6 +7600,28 @@ async function main() {
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCompactStart = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/正在执行 Codex App 原生 \/compact/.test(msg.message || '')
), 5000);
assert(/Codex App 原生 \/compact/.test(codexAppCompactStart.message || ''), 'Codex App /compact should announce native compaction');
const codexAppCompactRunning = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions?.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
), 5000);
assert(codexAppCompactRunning.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /compact should mark the session running');
const codexAppCompactDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/已执行 Codex App 原生 \/compact/.test(msg.message || '')
), 10000);
assert(/上下文压缩完成/.test(codexAppCompactDone.message || ''), 'Codex App /compact should complete through app-server');
const storedCodexAppAfterCompact = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexAppAfterCompact.codexAppThreadId === codexAppThreadId, 'Codex App /compact should keep the same thread');
assert(!storedCodexAppAfterCompact.messages.some((message) => message.role === 'user' && message.content === '/compact'), 'Codex App /compact should not persist the slash command as a user message');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool');
assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser');

407
server.js
View File

@@ -765,6 +765,12 @@ const activeCodexAppTurns = new Map();
// Active Codex app-server goal RPCs: sessionId -> { id, ws, action, cancelled }
const activeCodexAppGoalCommands = new Map();
// Latest Codex app-server Goal state: sessionId -> normalized ThreadGoal
const codexAppGoalStates = new Map();
// Active Codex app-server compact RPCs: sessionId -> { id, ws, threadId, cancelled }
const activeCodexAppCompactions = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
@@ -863,7 +869,7 @@ const DEFAULT_CODEX_CONFIG = {
activeProfile: '',
profiles: [],
enableSearch: false,
supportsSearch: false,
supportsSearch: true,
retry: {
mode: 'limited',
intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)),
@@ -1308,9 +1314,8 @@ function loadCodexConfig() {
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
enableSearch: false,
supportsSearch: false,
storedEnableSearch: !!raw.enableSearch,
enableSearch: !!raw.enableSearch,
supportsSearch: true,
retry: normalizeCodexRetryConfig(raw.retry),
};
}
@@ -1327,7 +1332,7 @@ function saveCodexConfig(config) {
apiKey: String(profile?.apiKey || ''),
apiBase: String(profile?.apiBase || '').trim(),
})).filter((profile) => profile.name) : [],
enableSearch: false,
enableSearch: !!config.enableSearch,
retry: normalizeCodexRetryConfig(config.retry),
}, null, 2));
}
@@ -1342,9 +1347,8 @@ function getCodexConfigMasked() {
apiKey: maskSecret(profile.apiKey),
apiBase: profile.apiBase || '',
})),
enableSearch: false,
supportsSearch: false,
storedEnableSearch: !!config.storedEnableSearch,
enableSearch: !!config.enableSearch,
supportsSearch: true,
retry: normalizeCodexRetryConfig(config.retry),
};
}
@@ -3249,7 +3253,11 @@ function isCodexLikeSession(session) {
}
function isSessionRunning(sessionId) {
return activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId) || activeCodexAppGoalCommands.has(sessionId);
return activeProcesses.has(sessionId)
|| activeCodexAppTurns.has(sessionId)
|| activeCodexAppGoalCommands.has(sessionId)
|| isCodexAppGoalActive(sessionId)
|| activeCodexAppCompactions.has(sessionId);
}
function getRuntimeSessionId(session) {
@@ -3736,11 +3744,17 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
function setRuntimeSessionId(session, runtimeId) {
if (!session) return;
const agent = getSessionAgent(session);
if (agent === 'codex') {
session.codexThreadId = runtimeId || null;
} else if (agent === 'codexapp') {
session.codexAppThreadId = runtimeId || null;
} else {
if (agent === 'codex') {
session.codexThreadId = runtimeId || null;
} else if (agent === 'codexapp') {
const previousThreadId = normalizeCodexAppThreadId(session.codexAppThreadId);
const nextThreadId = normalizeCodexAppThreadId(runtimeId);
session.codexAppThreadId = runtimeId || null;
if (previousThreadId && previousThreadId !== nextThreadId) {
session.codexAppGoal = null;
if (session.id) codexAppGoalStates.delete(session.id);
}
} else {
session.claudeSessionId = runtimeId || null;
}
}
@@ -4250,8 +4264,9 @@ function loadSessionMetaFromFile(filePath) {
hasUnread: !!session.hasUnread,
agent: getSessionAgent(session),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
codexAppGoalActive: isCodexThreadGoalActive(session.codexAppGoal),
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
}
@@ -4271,8 +4286,9 @@ function loadSessionMetaFromFile(filePath) {
hasUnread: previewBooleanField(previewFields, 'hasUnread'),
agent: normalizeAgent(previewStringField(previewFields, 'agent')),
cwd,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
fileBytes: stat.size,
projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '',
codexAppGoalActive: false,
fileBytes: stat.size,
oversized: stat.size > SESSION_LOAD_MAX_BYTES,
};
} catch (err) {
@@ -4290,8 +4306,9 @@ function loadSession(id) {
try {
const filePath = sessionPath(normalizedId);
if (!fs.existsSync(filePath)) return null;
const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
updateSessionRuntimeThreadIndex(session);
const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
syncCodexAppGoalStateFromSession(session);
updateSessionRuntimeThreadIndex(session);
return session;
} catch (err) {
plog('WARN', 'session_load_failed', {
@@ -5315,7 +5332,7 @@ function sendSessionList(ws) {
agent: normalizeAgent(meta.agent),
cwd: meta.cwd || '',
projectName: meta.projectName || '',
isRunning: isSessionRunning(meta.id),
isRunning: isSessionRunning(meta.id) || meta.codexAppGoalActive === true,
waitingOnChildren: waitState.waitingOnChildren,
pendingReplyCount: waitState.pendingReplyCount,
readyReplyCount: waitState.readyReplyCount,
@@ -6687,12 +6704,14 @@ function formatRuntimeError(agent, raw, context = {}) {
}
function compactStartMessage(agent) {
if (agent === 'codexapp') return '正在执行 Codex App 原生 /compact 压缩上下文,请稍候…';
return agent === 'codex'
? '正在执行 Codex /compact 压缩上下文,请稍候…'
: '正在执行 Claude 原生 /compact 压缩上下文,请稍候…';
}
function compactDoneMessage(agent) {
if (agent === 'codexapp') return '上下文压缩完成。已执行 Codex App 原生 /compact下次继续在同一会话发送即可。';
return agent === 'codex'
? '上下文压缩完成。已执行 Codex /compact下次继续在同一会话发送即可。'
: '上下文压缩完成。已按 Claude Code 原生策略执行 /compact下次继续在同一会话发送即可。';
@@ -7909,9 +7928,8 @@ function handleSaveCodexConfig(ws, newConfig) {
mode: newConfig.mode === 'custom' ? 'custom' : 'local',
activeProfile: String(newConfig.activeProfile || '').trim(),
profiles: mergedProfiles,
enableSearch: false,
supportsSearch: false,
storedEnableSearch: requestedSearch,
enableSearch: requestedSearch,
supportsSearch: true,
retry,
};
if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
@@ -7923,7 +7941,7 @@ function handleSaveCodexConfig(ws, newConfig) {
activeProfile: merged.activeProfile || null,
profileCount: merged.profiles.length,
enableSearchRequested: requestedSearch,
enableSearchEffective: false,
enableSearchEffective: merged.enableSearch,
retryMode: retry.mode,
retryIntervalSeconds: retry.intervalSeconds,
retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null,
@@ -7931,9 +7949,7 @@ function handleSaveCodexConfig(ws, newConfig) {
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
wsSend(ws, {
type: 'system_message',
message: requestedSearch
? 'Codex 配置已保存。当前 cc-web 的 Codex exec 路径暂未接入 Web Search已自动忽略该开关。'
: 'Codex 配置已保存',
message: 'Codex 配置已保存',
});
}
@@ -8050,6 +8066,63 @@ function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
};
}
function codexGoalStatusKey(status) {
return goalString(status || 'active').toLowerCase().replace(/[\s_-]/g, '');
}
function isCodexThreadGoalActive(goal) {
return !!goal && codexGoalStatusKey(goal.status) === 'active';
}
function isCodexAppGoalActive(sessionId) {
return isCodexThreadGoalActive(codexAppGoalStates.get(sessionId));
}
function syncCodexAppGoalStateFromSession(session) {
const sessionId = sanitizeId(session?.id || '');
if (!sessionId || !isCodexAppSession(session)) return null;
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
const goal = normalizeCodexThreadGoal(session.codexAppGoal, threadId || '');
if (!goal || (threadId && goal.threadId && goal.threadId !== threadId)) {
codexAppGoalStates.delete(sessionId);
return null;
}
codexAppGoalStates.set(sessionId, goal);
return goal;
}
function updateCodexAppGoalState(session, goal, options = {}) {
const sessionId = sanitizeId(session?.id || '');
if (!sessionId || !isCodexAppSession(session)) return null;
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
const normalized = normalizeCodexThreadGoal(goal, threadId || '');
const previous = normalizeCodexThreadGoal(
codexAppGoalStates.get(sessionId) || session.codexAppGoal,
threadId || '',
);
if (
options.force !== true
&& normalized
&& previous
&& normalized.threadId === previous.threadId
&& normalized.updatedAt > 0
&& previous.updatedAt > normalized.updatedAt
) {
return previous;
}
if (normalized) {
session.codexAppGoal = normalized;
codexAppGoalStates.set(sessionId, normalized);
} else {
session.codexAppGoal = null;
codexAppGoalStates.delete(sessionId);
}
if (options.persist !== false) saveSession(session);
if (options.broadcast !== false) broadcastSessionList();
return normalized;
}
function formatCodexGoalStatus(status) {
const normalized = String(status || 'active').trim();
const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
@@ -8133,6 +8206,122 @@ async function ensureCodexAppGoalThread(session) {
return { client, threadId };
}
function isCurrentCodexAppCompaction(sessionId, entry) {
return !!entry
&& activeCodexAppCompactions.get(sessionId)?.id === entry.id
&& !entry.cancelled;
}
function finishCodexAppCompaction(sessionId, entry) {
if (!entry || activeCodexAppCompactions.get(sessionId)?.id !== entry.id) return false;
activeCodexAppCompactions.delete(sessionId);
broadcastSessionList();
return true;
}
function cancelCodexAppCompaction(sessionId, ws = null) {
const entry = activeCodexAppCompactions.get(sessionId);
if (!entry) return false;
entry.cancelled = true;
activeCodexAppCompactions.delete(sessionId);
const targetWs = ws || entry.ws || null;
if (targetWs) {
wsSend(targetWs, {
type: 'system_message',
sessionId,
message: '已取消 Codex App /compact 状态。底层 app-server 请求可能仍会自然返回,结果将被忽略。',
});
}
broadcastSessionList();
return true;
}
function isCodexCompactUnsupportedError(err) {
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
return err?.code === -32601
|| /compact.*unsupported|unsupported.*compact|method not found|unknown mock method|unsupported remote app-server request/i.test(detail);
}
async function handleCodexAppCompactSlashCommand(ws, session, source = {}) {
if (!session || !isCodexAppSession(session)) return;
const sessionId = session.id;
const sendCompactResponse = (targetWs, payload, options = {}) => {
if (!targetWs) return;
wsSend(targetWs, attachClientRequestId({
...payload,
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
}, source));
};
const sendCompactSystemMessage = (targetWs, message, extra = {}, options = {}) => {
sendCompactResponse(targetWs, { type: 'system_message', message, ...extra }, options);
};
if (activeCodexAppCompactions.has(sessionId)) {
sendCompactSystemMessage(ws, 'Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
return;
}
const runtimeThreadId = getRuntimeSessionId(session);
if (!runtimeThreadId) {
sendCompactSystemMessage(ws, '当前会话尚未建立 Codex App 上下文,暂时无需压缩。', { sessionId }, { preserveComposerDraft: true });
return;
}
const compactEntry = {
id: crypto.randomUUID(),
ws,
threadId: runtimeThreadId,
cancelled: false,
startedAt: new Date().toISOString(),
};
activeCodexAppCompactions.set(sessionId, compactEntry);
sendCompactSystemMessage(ws, compactStartMessage('codexapp'), { sessionId });
broadcastSessionList();
try {
const clientResult = getCodexAppClient({ excludeSessionId: sessionId });
if (clientResult.error) throw new Error(clientResult.error);
const client = clientResult.client;
await client.start();
if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
const threadParams = codexAppThreadParams(session);
const resumed = await client.request('thread/resume', {
...threadParams,
threadId: runtimeThreadId,
}, 60000);
const threadId = resumed?.thread?.id || runtimeThreadId;
if (threadId !== runtimeThreadId) {
throw new Error(`Codex App 恢复到不同线程,已停止压缩(期望 ${String(runtimeThreadId).slice(0, 24)},实际 ${String(threadId).slice(0, 24)})。`);
}
const response = await client.request('thread/compact/start', { threadId }, 300000);
if (!isCurrentCodexAppCompaction(sessionId, compactEntry)) return;
session.updated = new Date().toISOString();
saveSession(session);
sendCompactSystemMessage(compactEntry.ws || ws, compactDoneMessage('codexapp'), { sessionId });
if (response?.threadId && response.threadId !== threadId) {
plog('WARN', 'codex_app_compact_thread_mismatch', {
sessionId: sessionId.slice(0, 8),
expectedThreadId: threadId,
actualThreadId: response.threadId,
});
}
} catch (err) {
if (isCurrentCodexAppCompaction(sessionId, compactEntry)) {
const message = isCodexCompactUnsupportedError(err)
? '当前 Codex app-server 不支持 /compact请升级 Codex 后重试。'
: `Codex App /compact 失败:${err?.message || err}`;
sendCompactSystemMessage(compactEntry.ws || ws, message, {
sessionId,
tone: 'danger',
}, { preserveComposerDraft: true });
}
} finally {
finishCodexAppCompaction(sessionId, compactEntry);
}
}
function isCodexGoalUnsupportedError(err) {
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
return err?.code === -32601
@@ -8256,6 +8445,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
const response = await client.request('thread/goal/get', { threadId }, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
updateCodexAppGoalState(session, goal);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
sendSessionList(targetWs);
@@ -8265,6 +8455,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
if (command.action === 'clear') {
const response = await client.request('thread/goal/clear', { threadId }, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
updateCodexAppGoalState(session, null);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', {
sessionId: session.id,
@@ -8283,6 +8474,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
}, 30000);
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
updateCodexAppGoalState(session, goal);
const targetWs = activeGoalCommand.ws || ws;
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal, { includeObjective: false }) : 'Goal updated', {
sessionId: session.id,
@@ -8341,6 +8533,10 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
return true;
}
if (session && isCodexAppSession(session) && activeCodexAppCompactions.has(sessionId)) {
sendSlashSystemMessage('Codex App /compact 正在执行,请稍候。', { sessionId }, { preserveComposerDraft: true });
return true;
}
switch (cmd) {
case '/clear': {
@@ -8452,7 +8648,9 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
break;
}
if (isCodexAppSession(session)) {
sendSlashSystemMessage('Codex App 模式暂不支持 /compact请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
handleCodexAppCompactSlashCommand(ws, session, source).catch((err) => {
sendSlashSystemMessage(`Codex App /compact 失败:${err?.message || err}`, { sessionId: session.id }, { preserveComposerDraft: true });
});
break;
}
const runtimeId = getRuntimeSessionId(session);
@@ -8531,7 +8729,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
sendSlashResponse({
type: 'system_message',
message: codexLikeAgent
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — 执行 Codex App 原生上下文压缩' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
: base + '\n/model [名称] — 查看/切换模型opus, sonnet, haiku\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
});
break;
@@ -9175,6 +9373,8 @@ function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
cancelCodexCapacityRetry(sessionId);
cancelCodexAppCompaction(sessionId);
codexAppGoalStates.delete(sessionId);
removeSessionRuntimeThreadIndex(sessionId);
if (activeCodexAppGoalCommands.has(sessionId)) {
const entry = activeCodexAppGoalCommands.get(sessionId);
@@ -9290,6 +9490,12 @@ function handleDisconnect(ws, wsId) {
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, turnId: entry.turnId || null });
}
}
for (const [sid, entry] of activeCodexAppCompactions) {
if (entry.ws === ws) {
entry.ws = null;
affectedSessions.push({ sessionId: sid.slice(0, 8), threadId: entry.threadId || null, compact: true });
}
}
wsSessionMap.delete(ws);
plog('INFO', 'ws_disconnect', { wsId, activeProcessesAffected: affectedSessions });
}
@@ -9305,6 +9511,7 @@ function bindAbortSessionToWs(sessionId, ws) {
activeProcesses.get(sessionId),
activeCodexAppTurns.get(sessionId),
activeCodexAppGoalCommands.get(sessionId),
activeCodexAppCompactions.get(sessionId),
].filter(Boolean);
if (entries.length > 0) detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
@@ -9314,13 +9521,81 @@ function bindAbortSessionToWs(sessionId, ws) {
}
}
function pauseCodexAppGoalForAbort(sessionId, ws = null) {
const session = loadSession(sessionId);
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
const currentGoal = normalizeCodexThreadGoal(
codexAppGoalStates.get(sessionId) || session?.codexAppGoal,
threadId || '',
);
if (!session || !threadId || !isCodexThreadGoalActive(currentGoal)) return false;
const pausedGoal = {
...currentGoal,
status: 'paused',
updatedAt: Math.max(Date.now(), Number(currentGoal.updatedAt || 0) + 1),
};
updateCodexAppGoalState(session, pausedGoal);
plog('INFO', 'codex_app_goal_pause_requested_by_abort', {
sessionId: sessionId.slice(0, 8),
threadId,
});
Promise.resolve().then(async () => {
const clientResult = getCodexAppClient();
if (clientResult.error) throw new Error(clientResult.error);
const client = clientResult.client;
await client.start();
const response = await client.request('thread/goal/set', {
threadId,
status: 'paused',
}, 30000);
const refreshedSession = loadSession(sessionId);
if (!refreshedSession) return;
const confirmedGoal = normalizeCodexThreadGoal(response?.goal, threadId);
if (confirmedGoal) updateCodexAppGoalState(refreshedSession, confirmedGoal);
if (ws) {
wsSend(ws, {
type: 'system_message',
sessionId,
tone: 'info',
transient: true,
autoDismissMs: 5000,
message: 'Goal paused',
});
}
}).catch((err) => {
const refreshedSession = loadSession(sessionId);
const latestGoal = codexAppGoalStates.get(sessionId);
if (
refreshedSession
&& codexGoalStatusKey(latestGoal?.status) === 'paused'
&& Number(latestGoal?.updatedAt || 0) === pausedGoal.updatedAt
) {
updateCodexAppGoalState(refreshedSession, currentGoal, { force: true });
}
if (ws) {
wsSend(ws, {
type: 'error',
sessionId,
code: 'codexapp_goal_pause_failed',
message: `暂停 Goal 失败:${err?.message || err}`,
});
}
});
return true;
}
function handleAbort(ws, msg = {}) {
const requestedSessionId = sanitizeId(msg?.sessionId || '');
const sessionId = requestedSessionId || wsSessionMap.get(ws);
if (!sessionId) return;
bindAbortSessionToWs(sessionId, ws);
if (handleCodexAppAbortSession(sessionId, ws)) return;
const goalPauseStarted = pauseCodexAppGoalForAbort(sessionId, ws);
const turnAbortStarted = handleCodexAppAbortSession(sessionId, ws);
if (turnAbortStarted || goalPauseStarted) return;
if (cancelCodexAppGoalCommand(sessionId, ws)) return;
if (cancelCodexAppCompaction(sessionId, ws)) return;
const entry = activeProcesses.get(sessionId);
if (!entry) {
if (cancelCodexCapacityRetry(sessionId)) {
@@ -9453,6 +9728,15 @@ function handleMessage(ws, msg, options = {}) {
return fail('session_running', 'Codex App Goal 正在同步,请稍候。');
}
if (sessionId && activeCodexAppCompactions.has(sessionId)) {
return fail('session_running', 'Codex App /compact 正在执行,请稍候。');
}
if (sessionId && !codexAppGoalStates.has(sessionId)) loadSession(sessionId);
if (sessionId && isCodexAppGoalActive(sessionId)) {
return fail('session_running', 'Goal 仍在持续运行,请先点击停止按钮暂停 Goal。');
}
if (sessionId && activeProcesses.has(sessionId)) {
return fail('session_running', '正在处理中,请先点击停止按钮。');
}
@@ -9825,6 +10109,12 @@ function detachWsFromActiveRuntimes(ws, options = {}) {
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
}
}
for (const [, entry] of activeCodexAppCompactions) {
if (entry.ws === ws) {
entry.ws = null;
if (disconnectTime) entry.wsDisconnectTime = disconnectTime;
}
}
}
function codexAppRuntimeThreadId(params = {}) {
@@ -10614,7 +10904,47 @@ function shouldLogCodexAppUnroutedNotification(notification) {
return true;
}
function handleCodexAppGoalNotification(notification) {
const method = String(notification?.method || '').trim();
if (method !== 'thread/goal/updated' && method !== 'thread/goal/cleared') return false;
const threadId = normalizeCodexAppThreadId(
notification?.params?.threadId || notification?.params?.thread?.id,
);
if (!threadId) return false;
const matched = findCodexAppSessionByThreadId(threadId);
if (!matched?.session) return false;
const goal = method === 'thread/goal/updated'
? normalizeCodexThreadGoal(notification?.params?.goal, threadId)
: null;
if (method === 'thread/goal/updated' && !goal) return false;
updateCodexAppGoalState(matched.session, goal);
plog('INFO', method === 'thread/goal/updated' ? 'codex_app_goal_updated' : 'codex_app_goal_cleared', {
sessionId: matched.sessionId.slice(0, 8),
threadId,
status: goal?.status || null,
tokensUsed: goal?.tokensUsed || 0,
});
return true;
}
function handleCodexAppNotification(notification) {
if (notification?.method === 'thread/compacted') {
const threadId = normalizeCodexAppThreadId(
notification?.params?.threadId
|| notification?.params?.thread_id
|| notification?.params?.thread?.id,
);
if (threadId) {
for (const entry of activeCodexAppCompactions.values()) {
if (entry.threadId === threadId) {
entry.compactedNotification = true;
return;
}
}
}
}
if (handleCodexAppGoalNotification(notification)) return;
const routed = findCodexAppRouteByRuntime(notification?.params || {}, notification?.method || '');
if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
if (!routed) {
@@ -11187,7 +11517,9 @@ function codexAppCcwebMcpEnv(session, options = {}) {
}
function codexAppThreadConfig(session, options = {}) {
const config = {};
const config = {
web_search: loadCodexConfig().enableSearch ? 'live' : 'disabled',
};
for (const item of listRuntimeMcpServerConfigs({ ...options, session, agent: 'codexapp' })) {
if (!item?.server || !item?.config) continue;
config[`mcp_servers.${item.server}`] = item.config;
@@ -11580,6 +11912,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
activeCodexAppTurns.delete(sessionId);
cleanupCodexAppTurnState(sessionId, entry);
const goalActive = isCodexAppGoalActive(sessionId);
dispatchTaskBoardLifecycle(sessionId, {
type: completionError
? TASK_BOARD_LIFECYCLE_EVENTS.TURN_FAILED
@@ -11590,7 +11923,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
outcome: completionError ? 'failed' : 'completed',
trackingEnabled: entry.taskTrackingEnabled === true,
});
if (session && !completionError && !options.interrupted && !entry.userAborted) {
if (session && !goalActive && !completionError && !options.interrupted && !entry.userAborted) {
const toolEvidence = assistantToolCalls.length > 0
? truncateTextValue(JSON.stringify(assistantToolCalls.map((toolCall) => ({
name: toolCall?.name || '',
@@ -11619,6 +11952,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
responseLen: (entry.fullText || '').length,
toolCallCount: (entry.toolCalls || []).length,
error: rawError || null,
goalActive,
});
if (entry.ws) {
@@ -11626,11 +11960,16 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
entry.errorSent = true;
wsSend(entry.ws, { type: 'error', sessionId, message: completionError });
}
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null });
wsSend(entry.ws, { type: 'done', sessionId, costUsd: null, goalActive });
sendSessionList(entry.ws);
return;
}
if (goalActive) {
broadcastSessionList();
return;
}
if (wss && session) {
for (const client of wss.clients) {
if (client.readyState === 1) {