diff --git a/.planning/codex-app-web-search/findings.md b/.planning/codex-app-web-search/findings.md new file mode 100644 index 0000000..44f7c2e --- /dev/null +++ b/.planning/codex-app-web-search/findings.md @@ -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 域返回 Forbidden,platform 域被 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 App,Codex App 的 `/compact`、`/goal`、协作模式、MCP、附件、标题、自动容量重试已有实现。 +- 仍需后续单独处理的遗留:后端可新建 `agent='codex'`,旧 `codex exec` 运行链路完整存在,rollout 导入与 regression 仍覆盖旧 CLI。它们属于迁移/封口任务,不在本轮 Web Search 范围内。 +- 未知 app-server request 的“不支持”兜底应保留;运行中已知 slash 的策略需要产品决定,不应与本轮 Web Search 混改。 diff --git a/.planning/codex-app-web-search/progress.md b/.planning/codex-app-web-search/progress.md new file mode 100644 index 0000000..5dfbb6b --- /dev/null +++ b/.planning/codex-app-web-search/progress.md @@ -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+重放”逻辑。 diff --git a/.planning/codex-app-web-search/task_plan.md b/.planning/codex-app-web-search/task_plan.md new file mode 100644 index 0000000..f1c53fc --- /dev/null +++ b/.planning/codex-app-web-search/task_plan.md @@ -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 交叉验证 | 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 a67a814..7ec186e 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/public/app.js b/public/app.js index e0170c0..43078d1 100644 --- a/public/app.js +++ b/public/app.js @@ -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 @@
+ +
容量失败重试
@@ -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 }); diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 4f0e79e..dfbc3e7 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -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; } diff --git a/scripts/regression.js b/scripts/regression.js index 562c482..b5e62dc 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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'); diff --git a/server.js b/server.js index c70244b..0a0593a 100644 --- a/server.js +++ b/server.js @@ -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) {