From bb42ca1f992beb32ffde0c527e778624f5706b75 Mon Sep 17 00:00:00 2001
From: Julian <148066842+JulianZJN@users.noreply.github.com>
Date: Mon, 14 Sep 2026 02:21:06 +0800
Subject: [PATCH 1/4] feat: select a separate model for Pro compaction
---
README.ja.md | 5 +
README.md | 4 +
README.zh-CN.md | 4 +
docs/architecture.md | 6 +
docs/compaction-model-selection.md | 71 +++
launcher/electron/main.cjs | 12 +
launcher/electron/preload.cjs | 1 +
launcher/electron/runtime-supervisor.cjs | 6 +
launcher/electron/runtime.cjs | 37 ++
launcher/src/App.tsx | 58 +++
launcher/src/i18n.ts | 21 +
launcher/src/styles.css | 22 +
launcher/src/types.ts | 3 +
launcher/tests/compaction-model.test.cjs | 153 ++++++
launcher/tests/runtime-supervisor.test.cjs | 12 +
.../chatgpt-web/browser-helper-main.ts | 28 +-
src/adapters/chatgpt-web/browser-worker.ts | 169 ++++++-
.../chatgpt-web/compaction-handoff.ts | 3 +
src/adapters/chatgpt-web/index.ts | 56 ++-
.../chatgpt-web/launcher-helper-client.ts | 37 ++
src/adapters/chatgpt-web/model.ts | 22 +-
src/chatgpt-web-compaction-policy.ts | 85 ++++
src/cli.ts | 27 +-
src/compaction-model-config.ts | 67 +++
src/config.ts | 16 +
src/dev-chat/cli.ts | 7 +
src/server.ts | 39 +-
src/types.ts | 4 +
tests/cli.test.ts | 74 +++
tests/compaction-execution-policy.test.ts | 113 +++++
tests/compaction-identity.test.ts | 21 +
tests/compaction-model-selection.test.ts | 475 ++++++++++++++++++
tests/fixtures/pro-model-picker.json | 23 +
tests/launcher-helper-client.test.ts | 134 ++++-
tests/retained-compaction.test.ts | 131 ++++-
tests/runtime-layout.test.ts | 32 ++
tests/server-compaction.test.ts | 52 ++
37 files changed, 1989 insertions(+), 41 deletions(-)
create mode 100644 docs/compaction-model-selection.md
create mode 100644 launcher/tests/compaction-model.test.cjs
create mode 100644 src/chatgpt-web-compaction-policy.ts
create mode 100644 src/compaction-model-config.ts
create mode 100644 tests/compaction-execution-policy.test.ts
create mode 100644 tests/compaction-identity.test.ts
create mode 100644 tests/compaction-model-selection.test.ts
create mode 100644 tests/fixtures/pro-model-picker.json
diff --git a/README.ja.md b/README.ja.md
index dde6af5ce..c0dc7200f 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -155,6 +155,11 @@ ChatGPT のツール呼び出しを現在の Codex タスクへ接続します
## 運用
+**設定 → Pro 圧縮モデル** では、自動 Pro タスクの要約だけに Extra High、GPT-5.6 Pro、
+GPT-5.5 Pro を選べます。通常のターンは元のモデルを使用し、既定値はメインタスクに従います。
+モデルの提供状況とアカウント上限は引き続き適用されます。
+詳しくは[圧縮モデルの選択](docs/compaction-model-selection.md)を参照してください。
+
安全なローカル診断には **アクティビティ**、エンドツーエンドのヘルスチェックには
**設定 → 診断を実行**を使用します。設定から、保持中のブラウザーターンのキャンセルや、
アンインストール前の Codex 統合削除も行えます。すべてのブラウザーチェックポイントでスクリーンショットが必要な場合にのみ、
diff --git a/README.md b/README.md
index 0fcb85d95..de577c6e7 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,10 @@ that option clicks **Allow once**, never a permanent grant.
Use **Activity** for safe local diagnostics and **Settings → Run doctor** for end-to-end health.
Settings can also cancel a retained browser turn or remove the Codex integration before uninstall.
+**Settings → Pro compaction model** optionally uses Extra High, GPT-5.6 Pro, or GPT-5.5 Pro
+for automatic Pro summaries while leaving ordinary task turns unchanged. The default follows the
+main task. Model availability and account limits still apply; see
+[compaction model selection](docs/compaction-model-selection.md).
Set `CODEX_CHATGPT_WEB_BROWSER_DIAGNOSTICS=1` only when every browser checkpoint needs a screenshot.
New installs use **Compatibility V1** for cross-backend subagents. **Native** preserves Codex's own
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e53249526..290f43995 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -147,6 +147,10 @@ bun run app
## 日常操作
+**设置 → Pro 任务压缩模型** 可为自动 Pro 任务的摘要单独选择 Extra High、GPT-5.6 Pro 或
+GPT-5.5 Pro,普通任务回合保持原模型。默认跟随主任务,仍受模型可用性和账户额度限制;详见
+[压缩模型选择](docs/compaction-model-selection.md)。
+
使用 **活动** 页面查看安全的本地诊断,并通过 **设置 → 运行诊断** 执行端到端健康检查。设置页还可
取消保留的浏览器任务,或在卸载前移除 Codex 集成。仅在需要为每个浏览器检查点保存截图时设置
`CODEX_CHATGPT_WEB_BROWSER_DIAGNOSTICS=1`。
diff --git a/docs/architecture.md b/docs/architecture.md
index 58f39ad9c..a6da0c55a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -155,6 +155,12 @@ every later tool action in the same turn continues to present the current turn c
ChatGPT status rows become reasoning summaries, while stable prose between rows becomes native
Codex commentary.
+Automatic Pro compaction can optionally use a separately selected summary model. The bridge
+keeps source ownership and transaction identity tied to the original request, and passes an
+execution-only policy through the helper protocol. Explicit policies require live pre-send
+effort/family verification and prohibit Pro staging. The default and handoff timeouts are unchanged;
+see [compaction model selection](compaction-model-selection.md).
+
## Installation and service lifecycle
Each native desktop package contains Electron, a platform-matched pinned Bun executable, the
diff --git a/docs/compaction-model-selection.md b/docs/compaction-model-selection.md
new file mode 100644
index 000000000..ed61af53e
--- /dev/null
+++ b/docs/compaction-model-selection.md
@@ -0,0 +1,71 @@
+# Optional model for Pro compaction
+
+**Settings → Pro compaction model** controls the summary step of an automatic
+ChatGPT Web Pro task. It does not change the model used for ordinary task turns.
+
+| Choice | Summary execution |
+| --- | --- |
+| Follow main task (default) | Existing compaction behavior, with no override |
+| GPT-5.6 Extra High | Explicit GPT-5.6 family at Extra High effort, not Pro |
+| GPT-5.6 Pro | Explicit GPT-5.6 family at Pro effort |
+| GPT-5.5 Pro | Explicit GPT-5.5 family at Pro effort |
+
+The setting is sampled when a new compaction starts. Saving a different choice
+does not interrupt an active turn or compaction, restart the service, or change
+Codex's model catalogue. Choosing the default removes the optional preference.
+Zero Risk, Luna, non-Pro tasks, and accounts without the required capabilities
+keep their existing behavior.
+
+## Why choose a different summary model?
+
+Extra High avoids a Pro submission for the summary. Choosing an older Pro family
+still uses that family's Pro allowance; it is an alternative for users who want
+Pro for the checkpoint without selecting the same family as their main task.
+Availability, shared allowances and reset schedules depend on the account and
+ChatGPT's current rules. This is explicit model selection, not a quota bypass.
+See the [official model and usage-limit documentation](https://help.openai.com/en/articles/20001354-gpt-56-in-chatgpt).
+
+Reducing summary latency and avoiding some Pro-specific failure modes are goals,
+not guaranteed outcomes. Changing the summary model can change checkpoint
+quality. It does not repair browser disconnects, authentication failures, invalid
+handoffs, or an unavailable model.
+
+## Execution boundaries
+
+1. Codex decides when to compact and sends its canonical history and native turn
+ identity to the bridge. This setting does not change context thresholds.
+2. The original request still identifies the retained source, shared compaction
+ transaction, cancellation and retirement. Only the summary execution gets the
+ selected model/effort override.
+3. The existing retained-source handoff is preserved. An automatic task without
+ a usable retained source uses the existing read-only fresh-chat fallback.
+ Browser-only summaries use the same execution preference. Summary generation
+ does not gain ordinary Codex work tools.
+4. For an explicit override, multipart staging is restricted to non-Pro levels.
+ An indivisible record that requires Pro's larger message envelope fails before
+ sending; the bridge does not silently spend a Pro request on staging.
+5. Before submission, the live model controls must prove the requested effort
+ and family. A missing family, changed
+ control or unavailable effort causes an explicit error, not a fallback.
+6. The existing checkpoint validation and browser-release checks still run.
+ Codex receives its replacement context and subsequent work uses the main
+ task's original model.
+
+The five-minute structured handoff liveness budget is unchanged. Existing
+multipart progress acknowledgements can renew the relevant phase budget; this
+is not a new fifty-minute timeout or an automatic retry of an accepted prompt.
+
+## Configuration and compatibility
+
+The optional core configuration field is `compactionModel`, with values
+`extra-high`, `5.6-pro`, or `5.5-pro`. An omitted field means follow-main.
+Use the launcher setting to persist it through the authenticated configuration
+command. The override is not part of the retained source's execution namespace.
+
+Both runtime and browser helper must support the `compaction-execution` feature.
+An older helper cannot silently ignore an explicit choice. Unknown settings and
+inconsistent execution payloads are rejected.
+
+The production service reads this preference for each new eligible compaction.
+The isolated DEV named-chat CLI snapshots its configuration at startup: reopen
+that CLI after changing the DEV preference. The launcher itself need not restart.
diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs
index 492b95087..b2c3813bf 100644
--- a/launcher/electron/main.cjs
+++ b/launcher/electron/main.cjs
@@ -408,6 +408,13 @@ function validateBrowserInteractionMode(value) {
return value;
}
+function validateCompactionModel(value) {
+ if (value !== null && value !== "extra-high" && value !== "5.6-pro" && value !== "5.5-pro") {
+ throw new Error("Compaction model must be follow, extra-high, 5.6-pro, or 5.5-pro");
+ }
+ return value;
+}
+
function validateBounds(value) {
if (!value || typeof value !== "object") throw new Error("Browser bounds are required");
for (const key of ["x", "y", "width", "height"]) {
@@ -430,6 +437,7 @@ function registerIpc({ logger, stateStore }) {
userData: launcherUserData,
},
state: stateStore.read(),
+ compactionModel: runtimeHost.compactionModel(),
browser: browserHost?.snapshot() ?? null,
connectorName: runtimeHost.browserConnectorName(),
connectorNames: {
@@ -819,6 +827,10 @@ function registerIpc({ logger, stateStore }) {
if (!IS_DEV_PROFILE && result.configured) startCatalogVerificationMonitor({ logger, stateStore });
return { state, credentialsRequired: false, targetMode: mode };
});
+ handle("launcher:compaction-model", async (_event, rawModel) => {
+ // Config-only: active turns and an already-started compaction keep their pinned model.
+ return runtimeHost.setCompactionModel(validateCompactionModel(rawModel));
+ });
handle("launcher:set-preference", (_event, key, value) => {
const ordinary = key === "keepRunningOnClose" || key === "showBrowserDuringTurns";
if (!ordinary) throw new Error("Unknown preference");
diff --git a/launcher/electron/preload.cjs b/launcher/electron/preload.cjs
index b474b285e..d51802e8c 100644
--- a/launcher/electron/preload.cjs
+++ b/launcher/electron/preload.cjs
@@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld("codexWebLauncher", {
setAutostart: (enabled) => ipcRenderer.invoke("launcher:autostart", enabled),
setBiggerContext: (enabled) => ipcRenderer.invoke("launcher:bigger-context", enabled),
setZeroRiskPro: (enabled) => ipcRenderer.invoke("launcher:zero-risk-pro", enabled),
+ setCompactionModel: (model) => ipcRenderer.invoke("launcher:compaction-model", model),
setBrowserInteractionMode: (mode) => ipcRenderer.invoke("launcher:browser-interaction-mode", mode),
setPreference: (key, value) => ipcRenderer.invoke("launcher:set-preference", key, value),
setSidebarState: (state) => ipcRenderer.invoke("launcher:sidebar-state", state),
diff --git a/launcher/electron/runtime-supervisor.cjs b/launcher/electron/runtime-supervisor.cjs
index a05443f49..dd9dff21b 100644
--- a/launcher/electron/runtime-supervisor.cjs
+++ b/launcher/electron/runtime-supervisor.cjs
@@ -255,6 +255,12 @@ function validateConfig(config, descriptorPath, platform = process.platform, lau
&& typeof config.experimentalBiggerContext !== "boolean") {
throw new Error("Runtime configuration has an invalid experimentalBiggerContext");
}
+ if (config.compactionModel !== undefined
+ && config.compactionModel !== "extra-high"
+ && config.compactionModel !== "5.6-pro"
+ && config.compactionModel !== "5.5-pro") {
+ throw new Error("Runtime configuration has an invalid compactionModel");
+ }
if (config.stallTimeoutSec !== undefined
&& (!Number.isFinite(config.stallTimeoutSec) || config.stallTimeoutSec <= 0)) {
throw new Error("Runtime configuration has an invalid stallTimeoutSec");
diff --git a/launcher/electron/runtime.cjs b/launcher/electron/runtime.cjs
index 49fdcf044..0d55a153a 100644
--- a/launcher/electron/runtime.cjs
+++ b/launcher/electron/runtime.cjs
@@ -1096,6 +1096,43 @@ class RuntimeHost {
return { ...result, mode, enabled: enabled === true };
}
+ compactionModel() {
+ return this.runtimeConfigSnapshot().config?.compactionModel ?? null;
+ }
+
+ async setCompactionModel(value) {
+ if (value !== null && value !== "extra-high" && value !== "5.6-pro" && value !== "5.5-pro") {
+ throw new Error("Compaction model must be follow, extra-high, 5.6-pro, or 5.5-pro");
+ }
+ const current = this.runtimeConfigSnapshot();
+ if (!current.configured) {
+ throw new Error("Install the Codex integration before changing the compaction model");
+ }
+ if ((current.config?.compactionModel ?? null) === value) return { compactionModel: value };
+ const args = [
+ ...(this.launcherProfile === "development" ? ["dev"] : []),
+ "config",
+ "compaction-model",
+ value ?? "follow",
+ "--launcher-control",
+ ];
+ await this.run("compaction-model", args, {
+ ...(this.launcherProfile === "development" ? {
+ embedded: true,
+ environment: this.devSetupEnvironment(),
+ } : {}),
+ env: this.launcherControlEnvironment(),
+ message: "Saving the Pro compaction model",
+ successMessage: "Pro compaction model saved for the next compaction",
+ timeoutMs: CORE_SETUP_TIMEOUT_MS,
+ });
+ const saved = this.compactionModel();
+ if (saved !== value) {
+ throw new Error("Runtime configuration did not persist the requested compaction model");
+ }
+ return { compactionModel: saved };
+ }
+
async setZeroRiskPro(enabled) {
const current = this.runtimeConfigSnapshot();
if (!current.configured) {
diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx
index bee8f1c01..f94983591 100644
--- a/launcher/src/App.tsx
+++ b/launcher/src/App.tsx
@@ -14,6 +14,7 @@ import { Icon, type IconName } from "./icons";
import type {
BrowserInteractionMode,
BrowserState,
+ CompactionModel,
DoctorReport,
Language,
LauncherSnapshot,
@@ -97,6 +98,10 @@ export function App() {
: current);
}, []);
+ const updateCompactionModel = useCallback((compactionModel: CompactionModel) => {
+ setSnapshot((current) => current ? { ...current, compactionModel } : current);
+ }, []);
+
if (!api) return ;
if (!snapshot) return ;
@@ -130,6 +135,7 @@ export function App() {
operation={operation}
setError={setError}
snapshot={snapshot}
+ updateCompactionModel={updateCompactionModel}
updateState={updateState}
/>
)}
@@ -330,6 +336,7 @@ function LauncherShell({
operation,
setError,
snapshot,
+ updateCompactionModel,
updateState,
}: {
browser: BrowserState | null;
@@ -339,6 +346,7 @@ function LauncherShell({
operation: OperationState | null;
setError: (error: string | null) => void;
snapshot: LauncherSnapshot;
+ updateCompactionModel: (value: CompactionModel) => void;
updateState: (state: LauncherState) => void;
}) {
const interactionSetupComplete = snapshot.state.coreSetupComplete === true
@@ -706,6 +714,7 @@ function LauncherShell({
language={language}
setError={setError}
snapshot={snapshot}
+ updateCompactionModel={updateCompactionModel}
updateState={updateState}
/>
) : null}
@@ -1582,6 +1591,7 @@ function SettingsSurface({
language,
setError,
snapshot,
+ updateCompactionModel,
updateState,
}: {
configureInteractionMode: (mode: BrowserInteractionMode) => void;
@@ -1590,6 +1600,7 @@ function SettingsSurface({
language: Language;
setError: (error: string | null) => void;
snapshot: LauncherSnapshot;
+ updateCompactionModel: (value: CompactionModel) => void;
updateState: (state: LauncherState) => void;
}) {
const [doctor, setDoctor] = useState(null);
@@ -1650,6 +1661,18 @@ function SettingsSurface({
setBusy(false);
}
};
+ const setCompactionModel = async (value: CompactionModel) => {
+ setBusy(true);
+ setError(null);
+ try {
+ const result = await api!.setCompactionModel(value);
+ updateCompactionModel(result.compactionModel);
+ } catch (cause) {
+ setError(messageOf(cause));
+ } finally {
+ setBusy(false);
+ }
+ };
const uninstallIntegration = async () => {
setBusy(true);
setError(null);
@@ -1684,6 +1707,14 @@ function SettingsSurface({
mode={snapshot.state.browserInteractionMode}
onChange={(mode) => void setInteractionMode(mode)}
/>
+
+ void setCompactionModel(value)}
+ value={snapshot.compactionModel}
+ />
+
void;
+ value: CompactionModel;
+}) {
+ return (
+
+ );
+}
+
function StateDot({ state }: { state: "idle" | "ready" | "busy" | "error" }) {
return ;
}
diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts
index 145056c98..e51928eee 100644
--- a/launcher/src/i18n.ts
+++ b/launcher/src/i18n.ts
@@ -151,6 +151,13 @@ const en = {
exportSafeLog: "Export safe log",
settingsTitle: "Launcher settings",
general: "General",
+ compactionModel: "Pro compaction model",
+ compactionModelBody: "For automatic Pro task summaries only; not manual mode, Luna, or non-Pro accounts. Applies to the next compaction, without changing the main task or restarting. Extra High uses no Pro quota for summaries; fixed Pro versions use the selected Pro quota. Unavailable models fail explicitly, with no fallback.",
+ devCompactionModelBody: "For automatic Pro summaries only; not manual mode, Luna, or non-Pro accounts. Reopen the DEV named-chat CLI to apply this choice; no launcher restart or main-task model change. Extra High summaries use no Pro quota; fixed Pro versions use the selected Pro quota. Unavailable models fail explicitly, with no fallback.",
+ compactionModelFollow: "Follow main (default)",
+ compactionModelExtraHigh: "GPT-5.6 Extra High",
+ compactionModel56Pro: "GPT-5.6 Pro",
+ compactionModel55Pro: "GPT-5.5 Pro",
launchAtLogin: "Launch at login",
launchAtLoginBody: "Keep the local Responses route available before Codex opens.",
keepRunningOnClose: "Keep server running when window closes",
@@ -343,6 +350,13 @@ const zh: Record = {
exportSafeLog: "导出安全日志",
settingsTitle: "启动器设置",
general: "通用",
+ compactionModel: "Pro 任务压缩模型",
+ compactionModelBody: "仅用于自动化 Pro 任务的摘要;手动模式、Luna 和非 Pro 账户不适用。从下一次压缩生效,不改变主任务,无需重启。Extra High 摘要不消耗 Pro 额度;固定 Pro 版本仍消耗所选 Pro 的额度。型号不可用时明确报错,不会静默回退。",
+ devCompactionModelBody: "仅用于自动化 Pro 摘要;手动模式、Luna 和非 Pro 账户不适用。重新打开 DEV 命名聊天 CLI 后采用新选择,无需重启启动器,也不改变主任务模型。Extra High 摘要不消耗 Pro 额度;固定 Pro 版本仍消耗所选 Pro 的额度。型号不可用时明确报错,不会静默回退。",
+ compactionModelFollow: "跟随主任务(默认)",
+ compactionModelExtraHigh: "GPT-5.6 Extra High",
+ compactionModel56Pro: "GPT-5.6 Pro",
+ compactionModel55Pro: "GPT-5.5 Pro",
launchAtLogin: "登录时启动",
launchAtLoginBody: "在 Codex 启动前保持本地 Responses 路由可用。",
keepRunningOnClose: "关闭窗口后保持服务器运行",
@@ -535,6 +549,13 @@ const ja: Record = {
exportSafeLog: "安全なログをエクスポート",
settingsTitle: "ランチャー設定",
general: "一般",
+ compactionModel: "Pro タスクの圧縮モデル",
+ compactionModelBody: "自動 Pro タスクの要約専用です。手動モード、Luna、非 Pro アカウントには適用されません。次の圧縮から有効になり、メインタスクの変更や再起動は不要です。Extra High の要約は Pro 枠を使いません。固定 Pro 版は選択した Pro 枠を使います。利用できないモデルは明示的にエラーとなり、暗黙に切り替えません。",
+ devCompactionModelBody: "自動 Pro 要約専用です。手動モード、Luna、非 Pro アカウントには適用されません。DEV 名前付きチャットの CLI を開き直すと反映されます。ランチャーの再起動やメインタスクのモデル変更は不要です。Extra High の要約は Pro 枠を使わず、固定 Pro 版は選択した Pro 枠を使います。利用できないモデルは明示的にエラーとなり、暗黙に切り替えません。",
+ compactionModelFollow: "メインに従う(既定)",
+ compactionModelExtraHigh: "GPT-5.6 Extra High",
+ compactionModel56Pro: "GPT-5.6 Pro",
+ compactionModel55Pro: "GPT-5.5 Pro",
launchAtLogin: "ログイン時に起動",
launchAtLoginBody: "Codex を開く前からローカル Responses ルートを利用できるようにします。",
keepRunningOnClose: "ウィンドウを閉じてもサーバーを実行",
diff --git a/launcher/src/styles.css b/launcher/src/styles.css
index 16c7bb18e..ae5464985 100644
--- a/launcher/src/styles.css
+++ b/launcher/src/styles.css
@@ -1915,6 +1915,28 @@ code {
padding: 12px 0;
}
+.settings-select {
+ max-width: 100%;
+ min-width: 160px;
+ height: 30px;
+ padding: 0 8px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-background-control);
+ color: var(--color-text-secondary);
+ font: var(--text-sm) var(--font-ui);
+ -webkit-app-region: no-drag;
+}
+
+.settings-select:focus-visible {
+ outline: 1px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+.settings-select:disabled {
+ opacity: 0.38;
+}
+
.language-menu {
position: relative;
z-index: 1;
diff --git a/launcher/src/types.ts b/launcher/src/types.ts
index e6451373b..b91cca4ed 100644
--- a/launcher/src/types.ts
+++ b/launcher/src/types.ts
@@ -1,6 +1,7 @@
export type Language = "en" | "zh-CN" | "ja";
export type LauncherProfile = "production" | "development";
export type BrowserInteractionMode = "automatic" | "manual";
+export type CompactionModel = "extra-high" | "5.6-pro" | "5.5-pro" | null;
export type Surface = "browser" | "setup" | "mcp" | "activity" | "settings";
export interface LauncherState {
@@ -99,6 +100,7 @@ export interface LauncherSnapshot {
userData: string;
};
state: LauncherState;
+ compactionModel: CompactionModel;
browser: BrowserState | null;
connectorName: string;
connectorNames: Record;
@@ -156,6 +158,7 @@ export interface LauncherApi {
setAutostart(enabled: boolean): Promise<{ state: LauncherState; supported: boolean; enabled: boolean }>;
setBiggerContext(enabled: boolean): Promise;
setZeroRiskPro(enabled: boolean): Promise;
+ setCompactionModel(model: CompactionModel): Promise<{ compactionModel: CompactionModel }>;
setBrowserInteractionMode(mode: BrowserInteractionMode): Promise<{
state: LauncherState;
credentialsRequired: boolean;
diff --git a/launcher/tests/compaction-model.test.cjs b/launcher/tests/compaction-model.test.cjs
new file mode 100644
index 000000000..03d7ebd4e
--- /dev/null
+++ b/launcher/tests/compaction-model.test.cjs
@@ -0,0 +1,153 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const ts = require("typescript");
+const { renderToStaticMarkup } = require("react-dom/server");
+const { RuntimeHost } = require("../electron/runtime.cjs");
+
+const launcherRoot = path.resolve(__dirname, "..");
+const source = (file) => fs.readFileSync(path.join(launcherRoot, file), "utf8");
+function compile(input) {
+ const output = ts.transpileModule(input, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2023, jsx: ts.JsxEmit.ReactJSX },
+ }).outputText;
+ const loaded = { exports: {} };
+ Function("module", "exports", "require", output)(loaded, loaded.exports, require);
+ return loaded.exports;
+}
+
+function fixture(t, development = false) {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-compaction-model-"));
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+ const configPath = path.join(directory, "config.json");
+ const original = {
+ mode: "full", browserHost: "launcher", browserInteractionMode: "automatic",
+ proAvailable: true, appName: "Codex Native2", tunnel: { id: "preserve-fixture" },
+ ...(development ? { purpose: "dev-harness" } : {}),
+ };
+ fs.writeFileSync(configPath, JSON.stringify(original));
+ const read = () => JSON.parse(fs.readFileSync(configPath, "utf8"));
+ const host = new RuntimeHost({
+ app: { getPath: () => directory, getVersion: () => "5.0.6" },
+ logger: { info() {}, warn() {}, error() {} },
+ sourceRoot: directory, coreHome: directory,
+ browserDescriptorPath: path.join(directory, "launcher-browser.json"),
+ launcherProfile: development ? "development" : "production",
+ supervisor: {
+ readConfig: read, readSetupConfig: read,
+ stopForSetup: async () => assert.fail("a preference must not stop the runtime"),
+ startIfConfigured: async () => assert.fail("a preference must not restart the runtime"),
+ },
+ getBrowserInteractionMode: () => "automatic",
+ });
+ host.launcherControlEnvironment = () => ({ CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN: "fixture-token" });
+ const calls = [];
+ // The CLI subprocess is the only external boundary; the getter reads actual persisted JSON.
+ host.run = async (name, args, options) => {
+ calls.push({ name, args, options });
+ const value = args[development ? 3 : 2];
+ const config = read();
+ if (value === "follow") delete config.compactionModel;
+ else config.compactionModel = value;
+ fs.writeFileSync(configPath, JSON.stringify(config));
+ return { code: 0, stdout: "", stderr: "" };
+ };
+ return { host, calls, read, original, directory };
+}
+
+test("compaction model choices roundtrip through config-only production and DEV commands", async (t) => {
+ for (const development of [false, true]) {
+ const { host, calls, read, original, directory } = fixture(t, development);
+ const prefix = development ? ["dev"] : [];
+ assert.equal(host.compactionModel(), null);
+ for (const value of ["extra-high", "5.6-pro", "5.5-pro", null]) {
+ assert.deepEqual(await host.setCompactionModel(value), { compactionModel: value });
+ const call = calls.at(-1);
+ assert.deepEqual(call.args, [...prefix, "config", "compaction-model", value ?? "follow", "--launcher-control"]);
+ assert.equal(call.options.env.CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN, "fixture-token");
+ if (development) {
+ assert.equal(call.options.embedded, true);
+ assert.equal(call.options.environment.CODEX_WEB_GPT_DEV_HOME, directory);
+ }
+ assert.deepEqual(read(), { ...original, ...(value ? { compactionModel: value } : {}) });
+ }
+ assert.equal(calls.length, 4);
+ assert.deepEqual(await host.setCompactionModel(null), { compactionModel: null });
+ assert.equal(calls.length, 4, "saving the already-selected default is a no-op");
+ }
+});
+
+test("compaction model rejects invalid values and verifies persistence instead of trusting command output", async (t) => {
+ const { host, read, original } = fixture(t);
+ host.run = async () => assert.fail("invalid values must not invoke the CLI");
+ for (const value of [undefined, "follow", "xhigh", "pro", "6-pro", 1, {}]) {
+ await assert.rejects(host.setCompactionModel(value), /Compaction model must be/);
+ }
+ host.run = async () => ({ code: 0, stdout: '{"compactionModel":"5.6-pro"}', stderr: "" });
+ await assert.rejects(host.setCompactionModel("5.6-pro"), /did not persist/);
+ assert.deepEqual(read(), original);
+ host.runtimeConfigSnapshot = () => ({ configured: false });
+ await assert.rejects(host.setCompactionModel("extra-high"), /Install the Codex integration/);
+});
+
+test("preload and main IPC validate and persist all compaction model choices", async (t) => {
+ const { host, read, original, calls } = fixture(t);
+ const parsed = ts.createSourceFile("main.cjs", source("electron/main.cjs"), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
+ const validator = parsed.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "validateCompactionModel");
+ const register = parsed.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "registerIpc");
+ const registration = register.body.statements.find((node) => ts.isExpressionStatement(node)
+ && ts.isCallExpression(node.expression)
+ && node.expression.arguments[0]?.text === "launcher:compaction-model");
+ assert.ok(validator && registration, "the production validation and IPC handler must exist");
+ const handlers = new Map();
+ // Execute production registration without Electron startup or browser/network side effects.
+ Function("handle", "runtimeHost", `${validator.getText(parsed)}\n${registration.getText(parsed)}`)(
+ (channel, handler) => handlers.set(channel, handler), host,
+ );
+ let api;
+ Function("require", source("electron/preload.cjs"))((name) => {
+ assert.equal(name, "electron");
+ return {
+ contextBridge: { exposeInMainWorld: (_name, value) => { api = value; } },
+ ipcRenderer: { invoke: async (channel, value) => handlers.get(channel)({}, value) },
+ };
+ });
+ for (const value of ["extra-high", "5.6-pro", "5.5-pro", null]) {
+ assert.deepEqual(await api.setCompactionModel(value), { compactionModel: value });
+ assert.equal(read().compactionModel ?? null, value);
+ }
+ const callsBeforeInvalid = calls.length;
+ for (const invalid of [undefined, "follow", "xhigh", "pro", "6-pro", {}]) {
+ await assert.rejects(api.setCompactionModel(invalid), /Compaction model must be/);
+ }
+ assert.equal(calls.length, callsBeforeInvalid);
+ assert.deepEqual(read(), original);
+});
+
+test("native compaction model menu renders localized choices and preserves selected values", () => {
+ const { copyFor } = compile(source("src/i18n.ts"));
+ const parsed = ts.createSourceFile("App.tsx", source("src/App.tsx"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+ const menu = parsed.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === "CompactionModelMenu");
+ assert.ok(menu, "the renderer compaction model menu must exist");
+ const { CompactionModelMenu } = compile(`export ${menu.getText(parsed)}`);
+ for (const language of ["en", "zh-CN", "ja"]) {
+ const copy = copyFor(language);
+ const selected = [];
+ const element = CompactionModelMenu({ copy, disabled: false, value: null, onChange: (value) => selected.push(value) });
+ assert.equal(element.props["aria-label"], copy.compactionModel);
+ assert.equal(element.props.value, "");
+ assert.deepEqual(element.props.children.map((option) => option.props.value), ["", "extra-high", "5.6-pro", "5.5-pro"]);
+ assert.equal(copy.compactionModelExtraHigh, "GPT-5.6 Extra High");
+ assert.equal(copy.compactionModel56Pro, "GPT-5.6 Pro");
+ assert.equal(copy.compactionModel55Pro, "GPT-5.5 Pro");
+ assert.ok(copy.compactionModelFollow.length > 0);
+ assert.match(renderToStaticMarkup(element), /value="" selected=""/);
+ for (const value of ["extra-high", "5.6-pro", "5.5-pro", ""]) element.props.onChange({ target: { value } });
+ assert.deepEqual(selected, ["extra-high", "5.6-pro", "5.5-pro", null]);
+ const disabled = CompactionModelMenu({ copy, disabled: true, value: "5.5-pro", onChange() {} });
+ assert.match(renderToStaticMarkup(disabled), /disabled=""/);
+ assert.match(renderToStaticMarkup(disabled), /value="5.5-pro" selected=""/);
+ }
+});
diff --git a/launcher/tests/runtime-supervisor.test.cjs b/launcher/tests/runtime-supervisor.test.cjs
index 2eb6acd5b..e564dd020 100644
--- a/launcher/tests/runtime-supervisor.test.cjs
+++ b/launcher/tests/runtime-supervisor.test.cjs
@@ -158,6 +158,18 @@ test("launcher runtime ownership cannot cross production and DEV profiles", () =
);
});
+test("launcher accepts only the supported persisted compaction model choices", () => {
+ const descriptorPath = path.join(os.tmpdir(), "launcher-compaction-model.json");
+ const config = launcherConfig(descriptorPath, { solAvailable: true });
+ assert.equal(validateConfig(config, descriptorPath).compactionModel, undefined);
+ for (const compactionModel of ["extra-high", "5.6-pro", "5.5-pro"]) {
+ assert.deepEqual(validateConfig({ ...config, compactionModel }, descriptorPath), { ...config, compactionModel });
+ }
+ for (const compactionModel of [null, "follow", "xhigh", "pro", "6-pro", 1]) {
+ assert.throws(() => validateConfig({ ...config, compactionModel }, descriptorPath), /invalid compactionModel/);
+ }
+});
+
test("DEV runtime supervision ignores launcher version mismatch and starts only the isolated MCP tunnel", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-web-gpt-dev-tunnel-supervisor-"));
const descriptorPath = path.join(root, "runtime", "launcher-browser.json");
diff --git a/src/adapters/chatgpt-web/browser-helper-main.ts b/src/adapters/chatgpt-web/browser-helper-main.ts
index b53e81805..440962352 100644
--- a/src/adapters/chatgpt-web/browser-helper-main.ts
+++ b/src/adapters/chatgpt-web/browser-helper-main.ts
@@ -9,6 +9,11 @@ import { createBrowserHelperPromptSelection } from "./browser-helper-prompt-sele
import type { CompiledChatGptWebPrompt } from "./prompt";
import { ChatGptMirroredTurnProgress } from "./turn-progress";
import type { ChatGptExternalTurnProgressSnapshot } from "./turn-progress";
+import { CHATGPT_WEB_BACKEND_MODEL } from "../../chatgpt-web-models";
+import {
+ parseChatGptWebCompactionExecution,
+ type ChatGptWebCompactionExecution,
+} from "../../chatgpt-web-compaction-policy";
interface RunMessage {
type: "run";
@@ -31,6 +36,7 @@ interface RunMessage {
requireRetainedConversation?: boolean;
conversationKey?: string;
compaction?: boolean;
+ compactionExecution?: BrowserTurn["compactionExecution"];
captureLunaCheckpoint?: boolean;
externalProgress?: boolean;
};
@@ -171,6 +177,22 @@ async function run(message: RunMessage): Promise {
if (message.turn.compaction !== undefined && typeof message.turn.compaction !== "boolean") {
throw new Error("Browser helper compaction flag is invalid");
}
+ let compactionExecution: ChatGptWebCompactionExecution | undefined;
+ if (message.turn.compactionExecution !== undefined) {
+ try {
+ compactionExecution = parseChatGptWebCompactionExecution(message.turn.compactionExecution);
+ } catch {
+ throw new Error("Browser helper compaction execution is invalid");
+ }
+ if (message.turn.compaction !== true
+ || message.turn.modelId !== CHATGPT_WEB_BACKEND_MODEL
+ || message.turn.reasoning !== compactionExecution.effort
+ || message.turn.capabilities?.localToolsEnabled !== false
+ || message.turn.capabilities?.solAvailable !== true
+ || message.turn.capabilities?.proAvailable !== true) {
+ throw new Error("Browser helper compaction execution is invalid");
+ }
+ }
if (message.turn.captureLunaCheckpoint !== undefined && typeof message.turn.captureLunaCheckpoint !== "boolean") {
throw new Error("Browser helper Luna checkpoint flag is invalid");
}
@@ -219,6 +241,7 @@ async function run(message: RunMessage): Promise {
...(message.turn.conversationKey ? { conversationKey: message.turn.conversationKey } : {}),
abortSignal: abortController.signal,
...(message.turn.compaction ? { compaction: true } : {}),
+ ...(compactionExecution ? { compactionExecution } : {}),
...(progress ? {
externalProgress: progress,
completionFence: {
@@ -517,4 +540,7 @@ process.once("SIGTERM", () => {
});
// Advertise the optional frames this helper understands so the daemon can negotiate them explicitly.
-writeProtocol({ type: "ready", features: ["progress", "tool-boundary-ack", "completion-fence", "multipart-stage-ack"] });
+writeProtocol({
+ type: "ready",
+ features: ["progress", "tool-boundary-ack", "completion-fence", "multipart-stage-ack", "compaction-execution"],
+});
diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts
index 815e97e82..5bb71a5bb 100644
--- a/src/adapters/chatgpt-web/browser-worker.ts
+++ b/src/adapters/chatgpt-web/browser-worker.ts
@@ -15,6 +15,7 @@ import {
} from "../../config";
import { estimateTokens } from "../../lib/token-estimate";
import type { CodexProviderConfig } from "../../types";
+import { parseChatGptWebCompactionExecution, type ChatGptWebCompactionExecution } from "../../chatgpt-web-compaction-policy";
import { parseDataUrl } from "../image";
import {
ChatGptMarkdownBuffer,
@@ -27,6 +28,7 @@ import {
resolveChatGptWebModelMode,
type ChatGptWebCapabilities,
type ChatGptWebModelMode,
+ type ChatGptWebCompactionModelVersion,
} from "./model";
import {
CHATGPT_LUNA_BROWSER_INPUT_TOKEN_BUDGET,
@@ -206,6 +208,70 @@ function chatGptModelControlUnavailableAdapterError(diagnostic: string): ChatGpt
);
}
+function chatGptPinnedModelError(version: ChatGptWebCompactionModelVersion, cause?: unknown): ChatGptWebAdapterError {
+ return new ChatGptWebAdapterError(
+ `ChatGPT model version ${version} could not be selected and verified. The pending prompt was not sent; check that this version is available in ChatGPT.`,
+ { status: 400, errorType: "invalid_request_error", code: "model_version_unavailable", retryable: false, cause },
+ );
+}
+
+function chatGptProModelOptionName(version: ChatGptWebCompactionModelVersion): RegExp {
+ if (version === "5.6") return /^GPT[-\s]?5\.6\s+Sol(?:\s+Pro)?$/i;
+ if (version === "5.5") return /^GPT[-\s]?5\.5(?:\s+Pro)?$/i;
+ throw new Error("Unsupported ChatGPT compaction model family");
+}
+
+function chatGptModelStateMatches(
+ descriptions: readonly string[],
+ version: ChatGptWebCompactionModelVersion | undefined,
+ requirePro: boolean,
+ expectedEffort?: ChatGptWebModelMode["effort"],
+): boolean {
+ // Parse each state independently before applying the pin. Filtering by the requested
+ // family first would hide contradictory state nodes (for example both 5.6 Pro and 6 Pro).
+ const statePrefix = /^(?:GPT[-\s]?)?(\d+(?:\.\d+)?)(?:\s+(Sol|Astra))?\s+(Instant|Medium|Extra High|High|即时|中|极高|高|Pro)(?=\s*(?:[,,]|$))/i;
+ const states = descriptions.flatMap(description => {
+ const state = statePrefix.exec(description.replace(/\s+/g, " ").trim());
+ return state ? [{ version: state[1]!, family: state[2]?.toLowerCase(), effort: state[3]!.toLowerCase() }] : [];
+ });
+ const labels: Record = {
+ low: ["instant", "即时"], medium: ["medium", "中"], high: ["high", "高"],
+ xhigh: ["extra high", "极高"], max: ["pro"],
+ };
+ return states.length > 0 && states.every(state => {
+ if (!["5.5", "5.6", "6"].includes(state.version)) return false;
+ if (version !== undefined && state.version !== version) return false;
+ if (state.family && state.family !== (state.version === "5.6" ? "sol" : state.version === "6" ? "astra" : undefined)) return false;
+ if (expectedEffort !== undefined) return labels[expectedEffort].includes(state.effort);
+ return !requirePro || state.effort === "pro";
+ });
+}
+
+async function assertChatGptSelectedModelVersion(
+ page: Page,
+ slider: Locator,
+ version: ChatGptWebCompactionModelVersion | undefined,
+ requirePro = false,
+ expectedEffort?: ChatGptWebModelMode["effort"],
+): Promise {
+ // The numeric slider is aria-hidden. Its keyboard menuitem owns the live spoken
+ // version/effort through aria-describedby, not aria-valuetext on the slider.
+ const keyboardControl = slider.locator("xpath=ancestor::*[@role='menuitem'][1]");
+ const descriptionIds = (await keyboardControl.getAttribute("aria-describedby"))?.trim().split(/\s+/).filter(Boolean) ?? [];
+ const descriptions = await page.evaluate(
+ ids => ids
+ .map(id => document.getElementById(id)?.textContent?.trim() ?? "")
+ .filter(Boolean),
+ descriptionIds,
+ );
+ // Version and Pro must come from the same described state node: unrelated instructions
+ // mentioning Pro are not proof that the selected effort is actually Pro.
+ if (!chatGptModelStateMatches(descriptions, version, requirePro, expectedEffort)) {
+ if (version) throw chatGptPinnedModelError(version);
+ throw chatGptModelControlUnavailableAdapterError("The live selector does not prove the requested summary effort");
+ }
+}
+
export type ChatGptPersonalizationPreflight = "already-personalized" | "enabled";
const CHATGPT_PERSONALIZATION_CONTROL_SELECTOR = [
@@ -966,6 +1032,7 @@ export function resolveChatGptWebMultipartStagingMode(
capabilities: ChatGptWebCapabilities,
maxStageMessageTokens: number,
maxStageChars: number,
+ allowProStaging = true,
): ChatGptWebModelMode {
if (modelId === CHATGPT_WEB_LUNA_MODEL_ID || !capabilities.solAvailable) {
throw new ChatGptWebAdapterError(
@@ -976,7 +1043,7 @@ export function resolveChatGptWebMultipartStagingMode(
if (modelId !== CHATGPT_WEB_MODEL_ID) {
throw new Error(`ChatGPT Bigger Context staging mode is not defined for model: ${modelId}`);
}
- const efforts: readonly ChatGptWebModelMode["effort"][] = capabilities.proAvailable
+ const efforts: readonly ChatGptWebModelMode["effort"][] = capabilities.proAvailable && allowProStaging
? ["low", "medium", "max"]
: ["low", "medium"];
for (const effort of efforts) {
@@ -1172,6 +1239,8 @@ export interface BrowserTurn {
};
/** Allow one clean pre-submit composer retry for isolated history compaction only. */
compaction?: boolean;
+ /** An explicitly selected, read-only summary execution (never an ordinary work turn). */
+ compactionExecution?: ChatGptWebCompactionExecution;
/** Require and remove the private Luna checkpoint tail from the visible Markdown stream. */
captureLunaCheckpoint?: boolean;
onLunaCheckpoint?: (captured: CapturedChatGptLunaCheckpoint) => void;
@@ -2321,8 +2390,9 @@ export class ChatGptBrowserWorker {
reasoning: string | undefined,
capabilities: ChatGptWebCapabilities,
captureDiagnostic?: (checkpoint: string) => Promise,
+ stageModelVersion?: ChatGptWebCompactionModelVersion,
): Promise {
- const mode = resolveChatGptWebModelMode(modelId, reasoning, capabilities);
+ const mode = resolveChatGptWebModelMode(modelId, reasoning, capabilities, stageModelVersion);
const composer = await this.activeComposer(page);
const composerForm = composer.locator("xpath=ancestor::form[1]");
const uiEffortIndex = mode.uiEffortIndex;
@@ -2361,7 +2431,35 @@ export class ChatGptBrowserWorker {
await throwIfChatGptRateLimitDialog(page);
await captureDiagnostic?.("effort-control-ready");
await throwIfChatGptRateLimitDialog(page);
- const activation = await activateChatGptEffortMenu(page, currentEffort);
+ let activation = await activateChatGptEffortMenu(page, currentEffort);
+ // Multipart preparation uses a lower effort, but must stay on the parent's pinned version.
+ const modelVersion = stageModelVersion ?? mode.modelVersion;
+ if (modelVersion) {
+ try {
+ let familyVerified = false;
+ try {
+ await assertChatGptSelectedModelVersion(page, activation.slider, modelVersion);
+ familyVerified = true;
+ } catch (error) {
+ if (!(error instanceof ChatGptWebAdapterError) || error.code !== "model_version_unavailable") throw error;
+ }
+ // Multipart staging changes effort, not family. An already verified family must not
+ // depend on reopening a model submenu after prior messages have been accepted.
+ // Missing/mismatched proof still requires exact selection and fresh verification.
+ if (!familyVerified) {
+ await activation.menu.getByLabel(/^(?:Select model|Choose model|选择模型|モデルを選択)$/).click({ timeout: 5_000 });
+ const modelName = chatGptProModelOptionName(modelVersion);
+ const option = activation.menu.getByRole("menuitemradio", { name: modelName, exact: true });
+ await option.waitFor({ state: "visible", timeout: 5_000 });
+ await option.click({ timeout: 5_000 });
+ await page.keyboard.press("Escape");
+ activation = await activateChatGptEffortMenu(page, currentEffort);
+ await assertChatGptSelectedModelVersion(page, activation.slider, modelVersion);
+ }
+ } catch (error) {
+ throw chatGptPinnedModelError(modelVersion, error);
+ }
+ }
if (activation.method === "pointerdown") {
await captureDiagnostic?.("effort-menu-pointerdown-fallback");
}
@@ -2402,6 +2500,12 @@ export class ChatGptBrowserWorker {
}
const targetValue = sliderState.min + uiEffortIndex;
if (targetValue > sliderState.max) {
+ if (modelVersion && mode.effort === "max") {
+ throw new ChatGptWebAdapterError(
+ `ChatGPT currently exposes only ${sliderState.max - sliderState.min + 1} effort levels for model ${modelVersion}; Pro is not available in this picker. The pending prompt was not sent, and no other model was substituted.`,
+ { status: 400, errorType: "invalid_request_error", code: "model_version_unavailable", retryable: false },
+ );
+ }
const proUsageLimitHint = uiEffortIndex === 4 && sliderState.min === 0 && sliderState.max === 3
? " If you have made many Pro requests recently, ChatGPT may have temporarily hidden Pro because you reached its usage limit."
: "";
@@ -2440,6 +2544,7 @@ export class ChatGptBrowserWorker {
);
}
}
+ if (modelVersion) await assertChatGptSelectedModelVersion(page, effortSlider, modelVersion, mode.effort === "max");
await captureDiagnostic?.("effort-selected");
await page.keyboard.press("Escape");
return mode;
@@ -3349,6 +3454,7 @@ export class ChatGptBrowserWorker {
submissionLifecycle?: Pick,
completionTracker?: ChatGptCompletionTracker,
recoverObservation?: ChatGptObservationRecovery,
+ expectedMode?: ChatGptWebModelMode & { verifyEffortBeforeSend?: boolean },
): Promise {
const composer = await this.activeComposer(page);
const sendButton = composer
@@ -3370,6 +3476,38 @@ export class ChatGptBrowserWorker {
await settleChatGptUi();
}
await captureDiagnostic?.("send-ready");
+ if (expectedMode?.modelVersion || expectedMode?.verifyEffortBeforeSend) {
+ // Connector attachment, file handling or a user action can reset the picker after selection.
+ // Recheck immediately before the irreversible send, without choosing a fallback model.
+ const control = composer.locator("xpath=ancestor::form[1]").locator(CHATGPT_EFFORT_CONTROL_SELECTOR).last();
+ let verificationError: ChatGptWebAdapterError | undefined;
+ try {
+ const { slider } = await activateChatGptEffortMenu(page, control);
+ await assertChatGptSelectedModelVersion(page, slider, expectedMode.modelVersion, expectedMode.effort === "max",
+ expectedMode.verifyEffortBeforeSend ? expectedMode.effort : undefined);
+ const state = parseChatGptEffortSliderState(
+ await slider.getAttribute("aria-valuemin"), await slider.getAttribute("aria-valuemax"),
+ await slider.getAttribute("aria-valuenow"),
+ );
+ if (!state || expectedMode.uiEffortIndex === null || state.value !== state.min + expectedMode.uiEffortIndex) {
+ if (expectedMode.modelVersion) throw chatGptPinnedModelError(expectedMode.modelVersion);
+ throw chatGptModelControlUnavailableAdapterError("The live effort position changed before summary submission");
+ }
+ } catch (error) {
+ verificationError = expectedMode.modelVersion ? chatGptPinnedModelError(expectedMode.modelVersion, error)
+ : chatGptModelControlUnavailableAdapterError("Could not verify the requested summary effort before submission");
+ throw verificationError;
+ } finally {
+ try {
+ await page.keyboard.press("Escape");
+ } catch (cleanupError) {
+ // Preserve a safety-relevant model mismatch instead of replacing it with cleanup noise.
+ // After successful verification, however, a menu that cannot be closed still fails closed.
+ if (!verificationError) throw cleanupError;
+ }
+ }
+ if (abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError");
+ }
const initialToolBatchRevision = externalProgress?.snapshot().lastToolBatchRevision ?? 0;
await submissionLifecycle?.onSendActivated?.();
await sendButton.press("Enter", {
@@ -4285,7 +4423,22 @@ export class ChatGptBrowserWorker {
const browserCapabilities = turn.nativeConnector
? { ...turn.capabilities, localToolsEnabled: true }
: turn.capabilities;
- const requestedMode = resolveChatGptWebModelMode(turn.modelId, turn.reasoning, browserCapabilities);
+ if (turn.compactionExecution !== undefined) {
+ let execution: ChatGptWebCompactionExecution;
+ try {
+ execution = parseChatGptWebCompactionExecution(turn.compactionExecution);
+ } catch (error) {
+ throw new Error("Explicit compaction execution has an invalid model/effort combination", { cause: error });
+ }
+ if (turn.compaction !== true || turn.capabilities.localToolsEnabled
+ || turn.modelId !== CHATGPT_WEB_MODEL_ID || !turn.capabilities.proAvailable
+ || turn.reasoning !== execution.effort) {
+ throw new Error("Explicit compaction execution requires a read-only summary with matching effort and an available model");
+ }
+ }
+ const requestedMode = resolveChatGptWebModelMode(
+ turn.modelId, turn.reasoning, browserCapabilities, turn.compactionExecution?.modelVersion,
+ );
const prepare = reuseConversation ? turn.prepareResume : turn.prepare;
if (!prepare) throw new Error("The retained ChatGPT conversation has no continuation prompt");
const prepared = await prepare();
@@ -4328,6 +4481,7 @@ export class ChatGptBrowserWorker {
browserCapabilities,
maxStageMessageTokens!,
maxStageChars!,
+ !turn.compactionExecution,
)
: requestedMode;
if (prepared.multipart) {
@@ -4523,6 +4677,7 @@ export class ChatGptBrowserWorker {
stagingMode.effort,
browserCapabilities,
checkpoint => diagnostics.capture(page, checkpoint),
+ requestedMode.modelVersion,
)
));
await diagnostics.capture(page, "effort-selection-complete");
@@ -4566,6 +4721,7 @@ export class ChatGptBrowserWorker {
return recovered;
}
: undefined,
+ turn.compactionExecution ? { ...mode, verifyEffortBeforeSend: true } : undefined,
),
);
console.info(
@@ -4623,6 +4779,7 @@ export class ChatGptBrowserWorker {
requestedMode.effort,
browserCapabilities,
checkpoint => diagnostics.capture(page, `final-part-${checkpoint}`),
+ requestedMode.modelVersion,
),
);
await diagnostics.capture(page, "final-part-effort-selected");
@@ -4679,8 +4836,9 @@ export class ChatGptBrowserWorker {
page,
turn.modelId,
turn.reasoning,
- turn.capabilities,
+ turn.compactionExecution ? browserCapabilities : turn.capabilities,
checkpoint => diagnostics.capture(page, checkpoint),
+ requestedMode.modelVersion,
);
submissionBaseline = await this.captureSubmissionBaseline(page);
},
@@ -4715,6 +4873,7 @@ export class ChatGptBrowserWorker {
return recovered;
}
: undefined,
+ turn.compactionExecution ? { ...requestedMode, verifyEffortBeforeSend: true } : undefined,
),
);
console.info(`[chatgpt-web] browser turn ${turn.traceId} submission accepted evidence=${finalSubmissionEvidence}`);
diff --git a/src/adapters/chatgpt-web/compaction-handoff.ts b/src/adapters/chatgpt-web/compaction-handoff.ts
index 6eecc581d..08b125727 100644
--- a/src/adapters/chatgpt-web/compaction-handoff.ts
+++ b/src/adapters/chatgpt-web/compaction-handoff.ts
@@ -6,6 +6,7 @@ import type {
} from "../../types";
import { extractChatGptCompactionSourceRevision } from "./environment";
import type { ChatGptBrowserWorker } from "./browser-worker";
+import type { ChatGptWebCompactionExecution } from "../../chatgpt-web-compaction-policy";
import { ChatGptCompactionHandoffAccepted } from "./adapter-error";
import type { CompactionTransactionHandle } from "./compaction-transaction";
import type { ChatGptWebCapabilities } from "./model";
@@ -283,6 +284,7 @@ export async function requestRetainedCompactionHandoff(
traceId: string,
signal?: AbortSignal,
timeoutMs = MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
+ compactionExecution?: ChatGptWebCompactionExecution,
): Promise {
const conversationKey = source.conversationKey();
if (!conversationKey) throw new Error("The completed ChatGPT source has no retained conversation identity");
@@ -320,6 +322,7 @@ export async function requestRetainedCompactionHandoff(
// not receive an ordinary Codex tool environment for this checkpoint message.
capabilities: { ...capabilities, localToolsEnabled: false },
nativeConnector: true,
+ ...(compactionExecution ? { compaction: true, compactionExecution } : {}),
prepare,
prepareResume: prepare,
conversationKey,
diff --git a/src/adapters/chatgpt-web/index.ts b/src/adapters/chatgpt-web/index.ts
index 9d538e69a..25774cdfe 100644
--- a/src/adapters/chatgpt-web/index.ts
+++ b/src/adapters/chatgpt-web/index.ts
@@ -2,6 +2,11 @@ import { createHash, randomBytes } from "node:crypto";
import { resolve } from "node:path";
import { isChatGptWebZeroRiskBackendModel } from "../../chatgpt-web-models";
import { defaultBrokerEndpoint, expandUserPath, resolveBrokerEndpoint } from "../../config";
+import {
+ parseChatGptWebCompactionModel,
+ resolveChatGptWebCompactionPlan,
+ type ChatGptWebCompactionExecution,
+} from "../../chatgpt-web-compaction-policy";
import {
cancelLauncherManualTurn,
endLauncherManualTurn,
@@ -193,9 +198,12 @@ function safeManualTerminalError(status: "cancelled" | "failed"): ChatGptWebAdap
}
export function chatGptWebExecutionNamespace(provider: CodexProviderConfig): string {
+ // Changing the next summary's model must not orphan the source conversation or permit
+ // a second owner for the same compaction round.
+ const { compactionModel: _summaryOnlyModel, ...identityConfig } = provider.chatgptWeb ?? {};
return createHash("sha256").update(JSON.stringify({
baseUrl: provider.baseUrl,
- chatgptWeb: provider.chatgptWeb ?? {},
+ chatgptWeb: identityConfig,
})).digest("hex");
}
@@ -349,6 +357,7 @@ export function createChatGptWebAdapter(
const structuredBroker = broker instanceof TurnBroker ? broker : undefined;
const timeoutMs = provider.chatgptWeb?.turnTimeoutMs;
const experimentalBiggerContext = provider.chatgptWeb?.experimentalBiggerContext;
+ const compactionModel = parseChatGptWebCompactionModel(provider.chatgptWeb?.compactionModel);
if (experimentalBiggerContext !== undefined && typeof experimentalBiggerContext !== "boolean") {
throw new Error("ChatGPT Bigger Context preference must be a boolean");
}
@@ -392,7 +401,10 @@ export function createChatGptWebAdapter(
environment: ReturnType | undefined,
traceId: string,
turnCapabilities: ChatGptWebCapabilities,
- hooks: { onCompactionProgress?: () => void } = {},
+ hooks: {
+ onCompactionProgress?: () => void;
+ compactionExecution?: ChatGptWebCompactionExecution;
+ } = {},
): ChatGptTurnRuntime => {
const manualRequest = isChatGptWebZeroRiskBackendModel(parsed.modelId);
if (manualRequest !== manualInteraction) {
@@ -685,6 +697,7 @@ export function createChatGptWebAdapter(
}),
abortSignal: browserAbort.signal,
...(parsed._compactionRequest ? { compaction: true } : {}),
+ ...(hooks.compactionExecution ? { compactionExecution: hooks.compactionExecution } : {}),
...submissionLifecycle,
...multipartProgressLifecycle,
onReasoningSummary: (text, continuation) => trace.push({ kind: "reasoning", text, ...(continuation ? { continuation: true } : {}) }),
@@ -849,6 +862,9 @@ export function createChatGptWebAdapter(
throw error;
}
}
+ // Only the summary's execution copy changes. All source, ownership, retry and retirement
+ // keys below intentionally continue to use the original parsed Pro request.
+ const compactionPlan = resolveChatGptWebCompactionPlan(parsed, compactionModel, turnCapabilities);
if (parsed._compactionRequest) {
const structuredCompactionRequired = parsed.modelId !== CHATGPT_WEB_LUNA_MODEL_ID
&& configuredCapabilities.localToolsEnabled;
@@ -931,11 +947,16 @@ export function createChatGptWebAdapter(
// transport time cannot consume the model-generation window.
armHandoffDeadline();
const fallbackRuntime = startRuntime(
- parsed,
+ compactionPlan.execution,
manualRequest ? environment : undefined,
`${handoffTraceId}_fallback`,
turnCapabilities,
- { onCompactionProgress: armHandoffDeadline },
+ {
+ onCompactionProgress: armHandoffDeadline,
+ ...(compactionPlan.compactionExecution
+ ? { compactionExecution: compactionPlan.compactionExecution }
+ : {}),
+ },
);
retainOwnershipUntil(fallbackRuntime.physicalSettlement);
try {
@@ -1002,13 +1023,14 @@ export function createChatGptWebAdapter(
preserveFinalResponse = !settlement.compactionInstructionDelivered;
rawSummary = await requestRetainedCompactionHandoff(
worker,
- parsed,
+ compactionPlan.execution,
source,
structuredBroker!,
configuredCapabilities,
handoffTraceId,
operationSignal,
handoffTimeoutMs,
+ compactionPlan.compactionExecution,
);
} else {
if (source.isActive()) {
@@ -1019,13 +1041,14 @@ export function createChatGptWebAdapter(
}
rawSummary = await requestRetainedCompactionHandoff(
worker,
- parsed,
+ compactionPlan.execution,
source,
structuredBroker!,
configuredCapabilities,
handoffTraceId,
operationSignal,
handoffTimeoutMs,
+ compactionPlan.compactionExecution,
);
}
const summary = canonicalizeCompactionHandoff(parsed, rawSummary);
@@ -1085,6 +1108,17 @@ export function createChatGptWebAdapter(
}
const handoffError = error instanceof Error ? error : new Error(String(error));
console.error("[chatgpt-web] structured context handoff failed:", handoffError);
+ if (compactionPlan.compactionExecution && handoffError instanceof ChatGptWebAdapterError) {
+ emit({
+ type: "error",
+ message: handoffError.message,
+ status: handoffError.status,
+ errorType: handoffError.errorType,
+ code: handoffError.code,
+ retryable: false,
+ });
+ return;
+ }
emit({
type: "error",
message: "ChatGPT did not complete the context handoff. Retry the task.",
@@ -1120,7 +1154,15 @@ export function createChatGptWebAdapter(
const session = await chatGptTurnSessions.getOrCreateAfterOwnerRetirement(
executionKey,
ownerKey,
- () => startRuntime(parsed, environment, traceId, turnCapabilities),
+ () => startRuntime(
+ compactionPlan.execution,
+ environment,
+ traceId,
+ turnCapabilities,
+ compactionPlan.compactionExecution
+ ? { compactionExecution: compactionPlan.compactionExecution }
+ : {},
+ ),
traceId,
incoming.abortSignal,
nativeTurnId,
diff --git a/src/adapters/chatgpt-web/launcher-helper-client.ts b/src/adapters/chatgpt-web/launcher-helper-client.ts
index 7e08fc753..7e5ae2ded 100644
--- a/src/adapters/chatgpt-web/launcher-helper-client.ts
+++ b/src/adapters/chatgpt-web/launcher-helper-client.ts
@@ -3,6 +3,11 @@ import { existsSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { createInterface } from "node:readline";
import { notifyLauncherTurn, readLauncherBrowserHostDescriptor } from "../../launcher-browser-host";
+import { CHATGPT_WEB_BACKEND_MODEL } from "../../chatgpt-web-models";
+import {
+ parseChatGptWebCompactionExecution,
+ type ChatGptWebCompactionExecution,
+} from "../../chatgpt-web-compaction-policy";
import { ChatGptCompactionHandoffAccepted, ChatGptWebAdapterError } from "./adapter-error";
import type { CompiledChatGptWebPrompt } from "./prompt";
import type { BrowserTurn, ResolvedBrowserConfig } from "./browser-worker";
@@ -23,6 +28,25 @@ interface PendingTurn {
acknowledgedMultipartStage?: number;
}
+function validateCompactionExecution(turn: BrowserTurn): ChatGptWebCompactionExecution | undefined {
+ if (turn.compactionExecution === undefined) return undefined;
+ let execution: ChatGptWebCompactionExecution;
+ try {
+ execution = parseChatGptWebCompactionExecution(turn.compactionExecution);
+ } catch {
+ throw new Error("Launcher browser compaction execution is invalid");
+ }
+ if (turn.compaction !== true
+ || turn.modelId !== CHATGPT_WEB_BACKEND_MODEL
+ || turn.reasoning !== execution.effort
+ || turn.capabilities.localToolsEnabled
+ || !turn.capabilities.solAvailable
+ || !turn.capabilities.proAvailable) {
+ throw new Error("Launcher browser compaction execution is invalid");
+ }
+ return execution;
+}
+
type HelperMessage =
| { type: "ready"; features?: string[] }
| { type: "event"; id: string; event: "heartbeat" | "send_activated" | "submitted" | "reasoning" | "commentary" | "text"; text?: string; continuation?: boolean }
@@ -227,6 +251,18 @@ export class LauncherBrowserHelperClient {
"Launcher browser helper does not support the MCP completion fence; update or restart the launcher",
);
}
+ const compactionExecution = validateCompactionExecution(turn);
+ if (compactionExecution && !this.helperFeatures.has("compaction-execution")) {
+ throw new ChatGptWebAdapterError(
+ "Launcher browser helper does not support compaction execution; update or restart the launcher",
+ {
+ status: 409,
+ errorType: "invalid_request_error",
+ code: "compaction_control_unavailable",
+ retryable: false,
+ },
+ );
+ }
return await new Promise((resolveResult, rejectResult) => {
if (this.pending.has(turn.traceId)) {
rejectResult(new Error(`Duplicate launcher browser turn: ${turn.traceId}`));
@@ -289,6 +325,7 @@ export class LauncherBrowserHelperClient {
...(turn.requireRetainedConversation ? { requireRetainedConversation: true } : {}),
...(turn.conversationKey ? { conversationKey: turn.conversationKey } : {}),
...(turn.compaction ? { compaction: true } : {}),
+ ...(compactionExecution ? { compactionExecution } : {}),
...(turn.captureLunaCheckpoint ? { captureLunaCheckpoint: true } : {}),
...(turn.externalProgress ? { externalProgress: true } : {}),
},
diff --git a/src/adapters/chatgpt-web/model.ts b/src/adapters/chatgpt-web/model.ts
index de58f8204..d3d0764d0 100644
--- a/src/adapters/chatgpt-web/model.ts
+++ b/src/adapters/chatgpt-web/model.ts
@@ -12,7 +12,11 @@ export interface ChatGptWebCapabilities {
proAvailable: boolean;
}
+export type ChatGptWebCompactionModelVersion = "5.5" | "5.6";
+
export interface ChatGptWebModelMode {
+ /** Explicit compaction-only model family; ordinary model resolution is unchanged. */
+ modelVersion?: ChatGptWebCompactionModelVersion;
modelId: string;
effort: "low" | "medium" | "high" | "xhigh" | "max";
displayLabel: "Luna" | "Think" | "Instant" | "Medium" | "High" | "Extra High" | "Pro";
@@ -21,7 +25,7 @@ export interface ChatGptWebModelMode {
localTools: boolean;
}
-export function resolveChatGptWebModelMode(
+function resolveBaseChatGptWebModelMode(
modelId: string,
reasoning: string | undefined,
capabilities: ChatGptWebCapabilities,
@@ -68,3 +72,19 @@ export function resolveChatGptWebModelMode(
throw new Error(`ChatGPT web effort is not supported: ${effort}`);
}
}
+
+/** A compaction family pin also applies to its lower-effort multipart stages. */
+export function resolveChatGptWebModelMode(
+ modelId: string,
+ reasoning: string | undefined,
+ capabilities: ChatGptWebCapabilities,
+ modelVersionOverride?: ChatGptWebCompactionModelVersion,
+): ChatGptWebModelMode {
+ const mode = resolveBaseChatGptWebModelMode(modelId, reasoning, capabilities);
+ if (modelVersionOverride === undefined) return mode;
+ if (modelId !== CHATGPT_WEB_MODEL_ID) throw new Error("Only the ChatGPT model selector supports a pinned model family");
+ if (modelVersionOverride !== "5.5" && modelVersionOverride !== "5.6") {
+ throw new Error("Unsupported ChatGPT compaction model family");
+ }
+ return { ...mode, modelVersion: modelVersionOverride };
+}
diff --git a/src/chatgpt-web-compaction-policy.ts b/src/chatgpt-web-compaction-policy.ts
new file mode 100644
index 000000000..0bb4b76d3
--- /dev/null
+++ b/src/chatgpt-web-compaction-policy.ts
@@ -0,0 +1,85 @@
+import { CHATGPT_WEB_BACKEND_MODEL } from "./chatgpt-web-models";
+import type { ChatGptWebCapabilities } from "./adapters/chatgpt-web/model";
+import type { CodexParsedRequest } from "./types";
+
+export type ChatGptWebCompactionModel = "extra-high" | "5.6-pro" | "5.5-pro";
+
+export type ChatGptWebCompactionExecution =
+ | { effort: "xhigh"; modelVersion: "5.6" }
+ | { effort: "max"; modelVersion: "5.5" | "5.6" };
+
+export interface ChatGptWebCompactionPlan {
+ /**
+ * Request used only for summary generation. Source identity, retained lookup, cancellation, and
+ * retirement must continue to use the original request passed to the resolver.
+ */
+ execution: CodexParsedRequest;
+ compactionExecution?: ChatGptWebCompactionExecution;
+}
+
+export function parseChatGptWebCompactionModel(
+ value: unknown,
+): ChatGptWebCompactionModel | undefined {
+ if (value === undefined) return undefined;
+ if (value === "extra-high" || value === "5.6-pro" || value === "5.5-pro") return value;
+ throw new Error(
+ "Invalid compactionModel; expected extra-high, 5.6-pro, 5.5-pro, or an omitted value",
+ );
+}
+
+/** Parse the exact daemon-to-browser contract and reject ambiguous effort/family combinations. */
+export function parseChatGptWebCompactionExecution(
+ value: unknown,
+): ChatGptWebCompactionExecution {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("Invalid compaction execution");
+ }
+ const record = value as Record;
+ const keys = Object.keys(record);
+ if (keys.some(key => key !== "effort" && key !== "modelVersion")) {
+ throw new Error("Invalid compaction execution");
+ }
+ if (record.effort === "xhigh" && record.modelVersion === "5.6" && keys.length === 2) {
+ return { effort: "xhigh", modelVersion: "5.6" };
+ }
+ if (record.effort === "max"
+ && (record.modelVersion === "5.5" || record.modelVersion === "5.6")
+ && keys.length === 2) {
+ return { effort: "max", modelVersion: record.modelVersion };
+ }
+ throw new Error("Invalid compaction execution");
+}
+
+function executionFor(model: ChatGptWebCompactionModel): ChatGptWebCompactionExecution {
+ switch (model) {
+ case "extra-high":
+ return { effort: "xhigh", modelVersion: "5.6" };
+ case "5.6-pro":
+ return { effort: "max", modelVersion: "5.6" };
+ case "5.5-pro":
+ return { effort: "max", modelVersion: "5.5" };
+ }
+}
+
+export function resolveChatGptWebCompactionPlan(
+ source: CodexParsedRequest,
+ configuredModel: ChatGptWebCompactionModel | undefined,
+ capabilities: ChatGptWebCapabilities,
+): ChatGptWebCompactionPlan {
+ const eligible = configuredModel !== undefined
+ && source._compactionRequest === true
+ && source.modelId === CHATGPT_WEB_BACKEND_MODEL
+ && source.options.reasoning === "max"
+ && capabilities.solAvailable
+ && capabilities.proAvailable;
+ if (!eligible) return { execution: source };
+
+ const compactionExecution = executionFor(configuredModel);
+ return {
+ execution: {
+ ...source,
+ options: { ...source.options, reasoning: compactionExecution.effort },
+ },
+ compactionExecution,
+ };
+}
diff --git a/src/cli.ts b/src/cli.ts
index b53bb38bf..92045f77f 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,7 +1,6 @@
#!/usr/bin/env bun
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
-import { timingSafeEqual } from "node:crypto";
import { existsSync, rmSync } from "node:fs";
import { isAbsolute } from "node:path";
import { stdin, stdout } from "node:process";
@@ -10,7 +9,6 @@ import { defaultConfig, getConfigDir, getConfigPath, loadConfig, loadConfigForSe
import {
inspectLauncherBrowserHost,
inspectLauncherBrowserHostLiveness,
- readLauncherBrowserHostDescriptor,
} from "./launcher-browser-host";
import {
activateCodexIntegration,
@@ -30,6 +28,10 @@ import { installRuntimeKeyBytes, managedRuntimeKeyPath, stopTunnel, tunnelStatus
import { getTunnelServiceStatus, restartTunnelService, startTunnelService, stopTunnelService, uninstallTunnelService } from "./tunnel-service";
import { VERSION } from "./version";
import { runDevCommand } from "./dev-chat/cli";
+import {
+ authorizeLauncherControl,
+ runCompactionModelConfigCommand,
+} from "./compaction-model-config";
const HELP = `codex-chatgpt-web ${VERSION}
@@ -42,6 +44,7 @@ Usage:
codex-chatgpt-web doctor [--json]
codex-chatgpt-web route
codex-chatgpt-web subagents
+ codex-chatgpt-web config compaction-model --launcher-control
codex-chatgpt-web browser check
codex-chatgpt-web dev launcher
codex-chatgpt-web dev status [--json]
@@ -137,21 +140,6 @@ function assertNoArgs(args: string[]): void {
if (args.length > 0) throw new Error(`Unknown arguments: ${args.join(" ")}`);
}
-function authorizeLauncherControl(operation: string): void {
- const descriptorPath = process.env.CODEX_CHATGPT_WEB_BROWSER_HOST_DESCRIPTOR?.trim();
- const supplied = process.env.CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN?.trim();
- delete process.env.CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN;
- if (!descriptorPath || !supplied) {
- throw new Error(`Launcher-controlled ${operation} requires a live launcher authorization`);
- }
- const descriptor = readLauncherBrowserHostDescriptor(descriptorPath);
- const expectedBytes = Buffer.from(descriptor.control.token);
- const suppliedBytes = Buffer.from(supplied);
- if (expectedBytes.length !== suppliedBytes.length || !timingSafeEqual(expectedBytes, suppliedBytes)) {
- throw new Error(`Launcher-controlled ${operation} authorization is invalid`);
- }
-}
-
function launcherLoginContinuation(): { promise: Promise; close: () => void } {
const maxBytes = 1_024;
let buffered = "";
@@ -558,6 +546,7 @@ async function main(): Promise {
else if (command === "doctor" || command === "status") await doctorCommand(args);
else if (command === "route") await routeCommand(args);
else if (command === "subagents") await subagentsCommand(args);
+ else if (command === "config") await runCompactionModelConfigCommand(args);
else if (command === "browser") {
const action = args.shift();
assertNoArgs(args);
@@ -578,7 +567,9 @@ async function main(): Promise {
} else if (command === "serve") {
assertNoArgs(args);
const config = loadConfig();
- const server = startServer(config);
+ const server = startServer(config, {
+ readCompactionModel: () => loadConfig().compactionModel,
+ });
stdout.write(`codex-chatgpt-web ${VERSION} listening on http://${config.host}:${server.port}/v1 (${config.mode})\n`);
await new Promise(() => {});
} else if (command === "dev") await runDevCommand(args);
diff --git a/src/compaction-model-config.ts b/src/compaction-model-config.ts
new file mode 100644
index 000000000..0305f4e95
--- /dev/null
+++ b/src/compaction-model-config.ts
@@ -0,0 +1,67 @@
+import { timingSafeEqual } from "node:crypto";
+import { resolve } from "node:path";
+import {
+ parseChatGptWebCompactionModel,
+ type ChatGptWebCompactionModel,
+} from "./chatgpt-web-compaction-policy";
+import { loadConfig, saveConfig, type AppConfig } from "./config";
+import { readLauncherBrowserHostDescriptor } from "./launcher-browser-host";
+
+export function authorizeLauncherControl(operation: string): string {
+ const descriptorPath = process.env.CODEX_CHATGPT_WEB_BROWSER_HOST_DESCRIPTOR?.trim();
+ const supplied = process.env.CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN?.trim();
+ delete process.env.CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN;
+ if (!descriptorPath || !supplied) {
+ throw new Error(`Launcher-controlled ${operation} requires a live launcher authorization`);
+ }
+ const descriptor = readLauncherBrowserHostDescriptor(descriptorPath);
+ const expectedBytes = Buffer.from(descriptor.control.token);
+ const suppliedBytes = Buffer.from(supplied);
+ if (expectedBytes.length !== suppliedBytes.length || !timingSafeEqual(expectedBytes, suppliedBytes)) {
+ throw new Error(`Launcher-controlled ${operation} authorization is invalid`);
+ }
+ return descriptorPath;
+}
+
+function updateCompactionModel(
+ config: AppConfig,
+ model: ChatGptWebCompactionModel | undefined,
+): void {
+ if (model === undefined) delete config.compactionModel;
+ else config.compactionModel = model;
+}
+
+export async function runCompactionModelConfigCommand(args: string[]): Promise {
+ const action = args.shift();
+ const rawModel = args.shift();
+ const launcherControlIndex = args.indexOf("--launcher-control");
+ const launcherControl = launcherControlIndex >= 0;
+ if (launcherControl) args.splice(launcherControlIndex, 1);
+ if (action !== "compaction-model" || !rawModel || args.length > 0) {
+ throw new Error(
+ "Config command must be: config compaction-model --launcher-control",
+ );
+ }
+ let model: ChatGptWebCompactionModel | undefined;
+ if (rawModel !== "follow") {
+ try {
+ model = parseChatGptWebCompactionModel(rawModel);
+ } catch {
+ throw new Error("Invalid compaction model; choose follow, extra-high, 5.6-pro, or 5.5-pro");
+ }
+ }
+ if (!launcherControl) {
+ throw new Error("Compaction model must be changed through Codex Web GPT Settings");
+ }
+ const authorizedDescriptorPath = authorizeLauncherControl("compaction model configuration");
+ const config = loadConfig();
+ if (config.browserHost !== "launcher" || !config.browserHostDescriptorPath
+ || resolve(config.browserHostDescriptorPath) !== resolve(authorizedDescriptorPath)) {
+ throw new Error("Launcher authorization does not own this configuration");
+ }
+ // The daemon samples this preference once when the next eligible compaction request begins.
+ // Saving it does not interrupt or retarget a browser operation that is already active.
+ updateCompactionModel(config, model);
+ saveConfig(config);
+ process.stdout.write(`${JSON.stringify({ compactionModel: model ?? null })}\n`);
+}
diff --git a/src/config.ts b/src/config.ts
index 75f8ac756..a49cc54c5 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -7,6 +7,10 @@ import {
CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL,
CHATGPT_WEB_ZERO_RISK_PRO_BACKEND_MODEL,
} from "./chatgpt-web-models";
+import {
+ parseChatGptWebCompactionModel,
+ type ChatGptWebCompactionModel,
+} from "./chatgpt-web-compaction-policy";
import type { CodexProviderConfig } from "./types";
import { VERSION } from "./version";
@@ -83,6 +87,8 @@ export interface AppConfig {
headed: boolean;
solAvailable: boolean;
proAvailable: boolean;
+ /** Optional model used only when an eligible automatic Pro turn compacts its history. */
+ compactionModel?: ChatGptWebCompactionModel;
experimentalBiggerContext: boolean;
/** Explicitly install the additional Pro-sized model row while Zero Risk is active. */
zeroRiskProEnabled: boolean;
@@ -483,6 +489,12 @@ function parseConfig(value: unknown, path: string): AppConfig {
if (parsed.proAvailable !== undefined && typeof parsed.proAvailable !== "boolean") {
throw new Error(`Invalid proAvailable in ${path}`);
}
+ let compactionModel: ChatGptWebCompactionModel | undefined;
+ try {
+ compactionModel = parseChatGptWebCompactionModel(parsed.compactionModel);
+ } catch {
+ throw new Error(`Invalid compactionModel in ${path}`);
+ }
if (parsed.solAvailable !== undefined && typeof parsed.solAvailable !== "boolean") {
throw new Error(`Invalid solAvailable in ${path}`);
}
@@ -516,6 +528,7 @@ function parseConfig(value: unknown, path: string): AppConfig {
subagentProtocol,
solAvailable,
proAvailable,
+ compactionModel,
experimentalBiggerContext,
zeroRiskProEnabled,
} as AppConfig;
@@ -570,6 +583,9 @@ export function providerConfig(config: AppConfig): CodexProviderConfig {
localToolsEnabled: config.mode === "full",
solAvailable: manual ? false : config.solAvailable,
proAvailable: manual ? false : config.proAvailable,
+ ...(!manual && config.compactionModel !== undefined
+ ? { compactionModel: config.compactionModel }
+ : {}),
experimentalBiggerContext: manual ? false : config.experimentalBiggerContext,
...(config.stallTimeoutSec !== undefined ? { stallTimeoutSec: config.stallTimeoutSec } : {}),
autoApproveToolCalls: manual ? false : config.autoApproveToolCalls,
diff --git a/src/dev-chat/cli.ts b/src/dev-chat/cli.ts
index 012f9dc80..fb0c31c47 100644
--- a/src/dev-chat/cli.ts
+++ b/src/dev-chat/cli.ts
@@ -30,12 +30,14 @@ import {
resolveDevProfilePaths,
} from "./profile";
import { DEV_CONFIG_PURPOSE, DEV_LAUNCHER_PROFILE } from "./constants";
+import { runCompactionModelConfigCommand } from "../compaction-model-config";
const DEV_HELP = `Codex Web GPT DEV chat
Usage:
codex-chatgpt-web dev launcher
codex-chatgpt-web dev status [--json]
+ codex-chatgpt-web dev config compaction-model --launcher-control
codex-chatgpt-web dev setup --browser-only [--automatic-browser-interaction]
codex-chatgpt-web dev setup --full --tunnel-id ID --runtime-key-file PATH [--automatic-browser-interaction|--zero-risk-browser-interaction]
codex-chatgpt-web dev chat NAME [--model MODEL] [MESSAGE]
@@ -335,6 +337,11 @@ export async function runDevCommand(args: string[]): Promise {
}
return;
}
+ if (action === "config") {
+ activateDevProfileEnvironment(paths);
+ await runCompactionModelConfigCommand(args);
+ return;
+ }
if (action === "setup") {
activateDevProfileEnvironment(paths);
const browserOnly = takeFlag(args, "--browser-only");
diff --git a/src/server.ts b/src/server.ts
index c02c5c58e..4d268dd8d 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -30,11 +30,13 @@ import {
type CodexModelContextOverride,
} from "./codex-integration";
import {
+ CHATGPT_WEB_BACKEND_MODEL,
CHATGPT_WEB_LUNA_BACKEND_MODEL,
isChatGptWebModelSlug,
requireChatGptWebModelRoute,
type ChatGptWebModelRoute,
} from "./chatgpt-web-models";
+import type { ChatGptWebCompactionModel } from "./chatgpt-web-compaction-policy";
import { forwardNativeCodexRequest, type NativeFetch, type NativeImageEndpoint } from "./native-passthrough";
import {
buildCompactV1Output,
@@ -359,6 +361,8 @@ export interface ResponseRequestOptions {
onAdapterEvent?: (event: AdapterEvent) => void;
/** Bind the physical HTTP stream to the exact native Codex turn that owns it. */
onTurnIdentity?: (identity: NativeCodexTurnIdentity) => void;
+ /** Sample the launcher-owned preference once when an eligible Pro compaction begins. */
+ readCompactionModel?: () => ChatGptWebCompactionModel | undefined;
}
export function routeChatGptWebRequest(parsed: CodexParsedRequest, config: AppConfig): ChatGptWebModelRoute {
@@ -511,6 +515,17 @@ export async function responseRequest(
}
const compaction = parsed._compactionRequest === true;
+ let requestConfig = config;
+ if (compaction
+ && route.interactionMode === "automatic"
+ && route.backendModel === CHATGPT_WEB_BACKEND_MODEL
+ && route.adapterEffort === "max"
+ && options.readCompactionModel) {
+ requestConfig = { ...config };
+ const compactionModel = options.readCompactionModel();
+ if (compactionModel === undefined) delete requestConfig.compactionModel;
+ else requestConfig.compactionModel = compactionModel;
+ }
const rememberCompletedResponse = (response: Record): void => {
if (!compaction) {
if (options.rememberState !== false) rememberResponseState(parsed._rawBody, response, { force: true });
@@ -550,7 +565,7 @@ export async function responseRequest(
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
}
- const provider = providerConfig(config);
+ const provider = providerConfig(requestConfig);
let traceId: string | undefined;
try {
traceId = chatGptWebTraceId(provider, parsed);
@@ -654,7 +669,7 @@ export async function compactRequest(
req: Request,
config: AppConfig,
adapterFactory: ChatGptWebAdapterFactory = createChatGptWebAdapter,
- options: Pick = {},
+ options: Pick = {},
): Promise {
const nativeRequest = req.clone();
let raw: Record;
@@ -769,7 +784,11 @@ export async function compactRequest(
export function startServer(
config: AppConfig,
- dependencies: { fetchUpstream?: NativeFetch; adapterFactory?: ChatGptWebAdapterFactory } = {},
+ dependencies: {
+ fetchUpstream?: NativeFetch;
+ adapterFactory?: ChatGptWebAdapterFactory;
+ readCompactionModel?: () => ChatGptWebCompactionModel | undefined;
+ } = {},
): ReturnType {
if (config.purpose === "dev-harness") {
throw new Error("DEV harness configuration cannot start a Responses listener");
@@ -991,7 +1010,12 @@ export function startServer(
new Request(req, { signal }),
config,
dependencies.adapterFactory,
- { onTurnIdentity: bindIdentity },
+ {
+ onTurnIdentity: bindIdentity,
+ ...(dependencies.readCompactionModel
+ ? { readCompactionModel: dependencies.readCompactionModel }
+ : {}),
+ },
),
req.signal,
process.platform,
@@ -1005,7 +1029,12 @@ export function startServer(
new Request(req, { signal }),
config,
dependencies.adapterFactory,
- { onTurnIdentity: bindIdentity },
+ {
+ onTurnIdentity: bindIdentity,
+ ...(dependencies.readCompactionModel
+ ? { readCompactionModel: dependencies.readCompactionModel }
+ : {}),
+ },
),
req.signal,
process.platform,
diff --git a/src/types.ts b/src/types.ts
index 9f56ee397..d0d7caa0a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,3 +1,5 @@
+import type { ChatGptWebCompactionModel } from "./chatgpt-web-compaction-policy";
+
export interface CodexParsedRequest {
modelId: string;
previousResponseId?: string;
@@ -299,6 +301,8 @@ export interface CodexProviderConfig {
solAvailable?: boolean;
/** Account capability proven by the authenticated browser probe. */
proAvailable?: boolean;
+ /** Optional model used only for eligible automatic Pro history compaction. */
+ compactionModel?: ChatGptWebCompactionModel;
/** Authorize per-call "Allow once" confirmation clicks for this connector. */
autoApproveToolCalls?: boolean;
/** DEV-only experimental transport: adapt one context across one, two, or three ChatGPT messages. */
diff --git a/tests/cli.test.ts b/tests/cli.test.ts
index bf8321252..3eee26e2c 100644
--- a/tests/cli.test.ts
+++ b/tests/cli.test.ts
@@ -175,6 +175,80 @@ test("passkey capture cannot be invoked outside the live Launcher control channe
}
});
+test("compaction model configuration requires launcher ownership before saving", async () => {
+ const root = mkdtempSync(join(tmpdir(), "codex-chatgpt-web-cli-compaction-auth-"));
+ const appHome = join(root, "app");
+ const descriptorPath = join(appHome, "runtime", "launcher-browser.json");
+ const helperScript = join(root, "helper.cjs");
+ const token = "launcher-compaction-control-token-0123456789abcdef";
+ try {
+ mkdirSync(join(appHome, "runtime"), { recursive: true });
+ writeFileSync(helperScript, "process.exit(0);\n", { mode: 0o700 });
+ writeFileSync(descriptorPath, `${JSON.stringify({
+ version: 3,
+ kind: "codex-web-gpt-launcher",
+ profile: "production",
+ pid: process.pid,
+ endpoint: "http://127.0.0.1:48201",
+ control: { endpoint: "http://127.0.0.1:48202", token },
+ helper: { executable: process.execPath, script: helperScript },
+ partition: "persist:codex-web-gpt-chatgpt",
+ idleUrl: LAUNCHER_BROWSER_IDLE_URL,
+ surfaceId: "c".repeat(32),
+ surfaceTargets: { ["c".repeat(32)]: "native-owned-target" },
+ createdAt: new Date().toISOString(),
+ })}\n`, { mode: 0o600 });
+ const config = defaultConfig("browser-only");
+ config.browserHost = "launcher";
+ config.browserHostDescriptorPath = descriptorPath;
+ writeFileSync(join(appHome, "config.json"), `${JSON.stringify(config)}\n`, { mode: 0o600 });
+
+ const unauthorized = await runCli([
+ "config", "compaction-model", "5.5-pro", "--launcher-control",
+ ], {
+ ...process.env,
+ CODEX_HOME: join(root, "codex"),
+ CODEX_CHATGPT_WEB_HOME: appHome,
+ CODEX_CHATGPT_WEB_BROWSER_HOST_DESCRIPTOR: descriptorPath,
+ });
+ expect(unauthorized.exitCode).toBe(1);
+ expect(unauthorized.stderr).toContain("requires a live launcher authorization");
+ expect(JSON.parse(readFileSync(join(appHome, "config.json"), "utf8")).compactionModel)
+ .toBeUndefined();
+
+ const authorizedEnv = {
+ ...process.env,
+ CODEX_HOME: join(root, "codex"),
+ CODEX_CHATGPT_WEB_HOME: appHome,
+ CODEX_CHATGPT_WEB_BROWSER_HOST_DESCRIPTOR: descriptorPath,
+ CODEX_WEB_GPT_LAUNCHER_CONTROL_TOKEN: token,
+ };
+ const selected = await runCli([
+ "config", "compaction-model", "5.5-pro", "--launcher-control",
+ ], authorizedEnv);
+ expect(selected).toEqual({
+ exitCode: 0,
+ stdout: `${JSON.stringify({ compactionModel: "5.5-pro" })}\n`,
+ stderr: "",
+ });
+ expect(JSON.parse(readFileSync(join(appHome, "config.json"), "utf8")).compactionModel)
+ .toBe("5.5-pro");
+
+ const follow = await runCli([
+ "config", "compaction-model", "follow", "--launcher-control",
+ ], authorizedEnv);
+ expect(follow).toEqual({
+ exitCode: 0,
+ stdout: `${JSON.stringify({ compactionModel: null })}\n`,
+ stderr: "",
+ });
+ expect(JSON.parse(readFileSync(join(appHome, "config.json"), "utf8"))).not
+ .toHaveProperty("compactionModel");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("DEV chat list works without starting launcher, broker, or Responses services", async () => {
const root = mkdtempSync(join(tmpdir(), "codex-chatgpt-web-cli-dev-list-"));
try {
diff --git a/tests/compaction-execution-policy.test.ts b/tests/compaction-execution-policy.test.ts
new file mode 100644
index 000000000..4e61a17b2
--- /dev/null
+++ b/tests/compaction-execution-policy.test.ts
@@ -0,0 +1,113 @@
+import { expect, test } from "bun:test";
+import {
+ parseChatGptWebCompactionExecution,
+ parseChatGptWebCompactionModel,
+ resolveChatGptWebCompactionPlan,
+ type ChatGptWebCompactionModel,
+} from "../src/chatgpt-web-compaction-policy";
+import type { CodexParsedRequest } from "../src/types";
+
+function compactRequest(overrides: Partial = {}): CodexParsedRequest {
+ return {
+ modelId: "gpt-5.6-sol",
+ stream: false,
+ context: { messages: [{ role: "user", content: "Compact this task", timestamp: 1 }] },
+ options: { reasoning: "max" },
+ _compactionRequest: true,
+ _rawBody: { marker: "source" },
+ ...overrides,
+ };
+}
+
+test.each([
+ ["extra-high", "xhigh", { effort: "xhigh", modelVersion: "5.6" }],
+ ["5.6-pro", "max", { effort: "max", modelVersion: "5.6" }],
+ ["5.5-pro", "max", { effort: "max", modelVersion: "5.5" }],
+] as const)("%s changes only the compaction execution copy", (configuredModel, reasoning, expectedExecution) => {
+ const source = compactRequest();
+ const before = structuredClone(source);
+ const plan = resolveChatGptWebCompactionPlan(source, configuredModel, {
+ localToolsEnabled: true,
+ solAvailable: true,
+ proAvailable: true,
+ });
+
+ expect(plan.execution).not.toBe(source);
+ expect(plan.execution.options).not.toBe(source.options);
+ expect(plan.execution.options.reasoning).toBe(reasoning);
+ expect(plan.compactionExecution).toEqual(expectedExecution);
+ expect(source).toEqual(before);
+ expect(plan.execution.context).toBe(source.context);
+ expect(plan.execution._rawBody).toBe(source._rawBody);
+});
+
+test("an omitted compaction model follows the main turn without an execution override", () => {
+ const source = compactRequest();
+ expect(resolveChatGptWebCompactionPlan(source, undefined, {
+ localToolsEnabled: false,
+ solAvailable: true,
+ proAvailable: true,
+ })).toEqual({ execution: source });
+});
+
+test.each([
+ ["normal turn", { _compactionRequest: false }],
+ ["non-Pro effort", { options: { reasoning: "high" } }],
+ ["Luna backend", { modelId: "gpt-5.6-luna" }],
+ ["Zero Risk backend", { modelId: "chatgpt-web-zero-risk" }],
+] as const)("explicit compaction policy does not affect a %s", (_label, requestOverrides) => {
+ const source = compactRequest(requestOverrides as Partial);
+ expect(resolveChatGptWebCompactionPlan(source, "extra-high", {
+ localToolsEnabled: true,
+ solAvailable: true,
+ proAvailable: true,
+ })).toEqual({ execution: source });
+});
+
+test.each([
+ [false, true],
+ [true, false],
+ [false, false],
+] as const)("explicit compaction policy requires Sol=%s and Pro=%s eligibility", (solAvailable, proAvailable) => {
+ const source = compactRequest();
+ expect(resolveChatGptWebCompactionPlan(source, "5.6-pro", {
+ localToolsEnabled: true,
+ solAvailable,
+ proAvailable,
+ })).toEqual({ execution: source });
+});
+
+test.each([
+ [undefined, undefined],
+ ["extra-high", "extra-high"],
+ ["5.6-pro", "5.6-pro"],
+ ["5.5-pro", "5.5-pro"],
+] as const)("compaction model parser accepts %p", (value, expected) => {
+ expect(parseChatGptWebCompactionModel(value)).toBe(expected as ChatGptWebCompactionModel | undefined);
+});
+
+test.each([null, true, "follow", "xhigh", "max", "5.7-pro"]) (
+ "invalid compaction model %p is rejected",
+ value => expect(() => parseChatGptWebCompactionModel(value)).toThrow("Invalid compactionModel"),
+);
+
+test.each([
+ [{ effort: "xhigh", modelVersion: "5.6" }, { effort: "xhigh", modelVersion: "5.6" }],
+ [{ effort: "max", modelVersion: "5.6" }, { effort: "max", modelVersion: "5.6" }],
+ [{ effort: "max", modelVersion: "5.5" }, { effort: "max", modelVersion: "5.5" }],
+] as const)("compaction execution parser accepts the exact %p contract", (value, expected) => {
+ expect(parseChatGptWebCompactionExecution(value)).toEqual(expected);
+});
+
+test.each([
+ [undefined],
+ [null],
+ [{ effort: "max" }],
+ [{ effort: "xhigh" }],
+ [{ effort: "xhigh", modelVersion: "5.5" }],
+ [{ effort: "high" }],
+ [{ effort: "max", modelVersion: "6" }],
+ [{ effort: "max", modelVersion: "5.6", extra: true }],
+])("invalid compaction execution %p is rejected", value => {
+ expect(() => parseChatGptWebCompactionExecution(value)).toThrow("Invalid compaction execution");
+});
diff --git a/tests/compaction-identity.test.ts b/tests/compaction-identity.test.ts
new file mode 100644
index 000000000..02486a943
--- /dev/null
+++ b/tests/compaction-identity.test.ts
@@ -0,0 +1,21 @@
+import { expect, test } from "bun:test";
+import { chatGptWebExecutionNamespace } from "../src/adapters/chatgpt-web";
+import type { CodexProviderConfig } from "../src/types";
+
+test("changing summary preference preserves the retained main task namespace", () => {
+ const provider: CodexProviderConfig = {
+ adapter: "chatgpt-web",
+ baseUrl: "http://localhost:17841/v1",
+ chatgptWeb: { solAvailable: true, proAvailable: true, localToolsEnabled: true },
+ };
+ const source = chatGptWebExecutionNamespace(provider);
+ for (const compactionModel of ["extra-high", "5.6-pro", "5.5-pro"] as const) {
+ expect(chatGptWebExecutionNamespace({
+ ...provider, chatgptWeb: { ...provider.chatgptWeb, compactionModel },
+ })).toBe(source);
+ }
+ // The exclusion is specific to the summary preference: capability changes still isolate work.
+ expect(chatGptWebExecutionNamespace({
+ ...provider, chatgptWeb: { ...provider.chatgptWeb, localToolsEnabled: false },
+ })).not.toBe(source);
+});
diff --git a/tests/compaction-model-selection.test.ts b/tests/compaction-model-selection.test.ts
new file mode 100644
index 000000000..73fe8d685
--- /dev/null
+++ b/tests/compaction-model-selection.test.ts
@@ -0,0 +1,475 @@
+import { expect, test } from "bun:test";
+import { ChatGptBrowserWorker, resolveChatGptWebMultipartStagingMode } from "../src/adapters/chatgpt-web/browser-worker";
+import observedPicker from "./fixtures/pro-model-picker.json";
+import { resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model";
+import {
+ CHATGPT_EFFORT_MENU_SELECTOR,
+ CHATGPT_EFFORT_SLIDER_CONTAINER_SELECTOR,
+} from "../src/chatgpt-session";
+
+// The live hidden slider has no aria-valuetext. The owned keyboard menuitem's
+// aria-describedby references expose its actual version and effort (sanitized DOM fixture).
+function picker(options: {
+ unavailable?: boolean;
+ actualVersion?: string;
+ max?: number;
+ descriptionTexts?: readonly string[];
+ effortLabels?: readonly string[];
+ versionAtMax?: string;
+ modelOptions?: Array<{ role: string; name: string; version: string }>;
+ initialVersion?: string;
+ versionTriggerUnavailable?: boolean;
+ numericValueMissing?: boolean;
+} = {}) {
+ let version = options.initialVersion ?? "6", value = 0, submenu = false;
+ let keyboardFailure: string | undefined;
+ const actions: string[] = [];
+ const submittedStates: Array<{ version: string; value: number }> = [];
+ const hidden = {
+ filter() { return this; }, last() { return this; }, getByText() { return this; },
+ isVisible: async () => false,
+ waitFor: ({ signal }: { signal: AbortSignal }) => new Promise((_, reject) => {
+ signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
+ }),
+ };
+ const sliderControl = {
+ getAttribute: async (name: string) => name === "aria-describedby" ? observedPicker.keyboardControl["aria-describedby"] : null,
+ press: async (key: string) => {
+ actions.push(`${version}:${key}`);
+ value += key === "ArrowRight" ? 1 : -1;
+ } };
+ const slider = {
+ waitFor: async () => {},
+ getAttribute: async (name: string) => ({
+ "aria-valuemin": "0", "aria-valuemax": String(options.max ?? 4), "aria-valuenow": options.numericValueMissing ? null : String(value),
+ })[name] ?? null,
+ locator: () => sliderControl,
+ };
+ const container = {
+ filter() { return this; }, last() { return this; }, locator: () => slider,
+ isVisible: async () => !submenu, waitFor: async () => {},
+ };
+ const control = {
+ last() { return this; }, waitFor: async () => {}, isVisible: async () => true,
+ getAttribute: async (name: string) => name === "aria-controls" ? "picker"
+ : name === "aria-expanded" ? "true" : null,
+ click: async () => { submenu = false; },
+ };
+ const versionTrigger = {
+ count: async () => 1, waitFor: async () => {},
+ click: async () => {
+ if (options.versionTriggerUnavailable) throw new Error("family picker is unavailable in this menu state");
+ submenu = true; actions.push("open-versions");
+ },
+ };
+ const modelOptions = options.modelOptions ?? observedPicker.options;
+ const radio = (name: string | RegExp) => {
+ const option = modelOptions.find(option => typeof name === "string" ? option.name === name : name.test(option.name));
+ return {
+ count: async () => options.unavailable ? 0 : 1,
+ waitFor: async () => { if (options.unavailable || !option) throw new Error("missing version"); },
+ getAttribute: async (attribute: string) => attribute === "aria-checked" ? "false" : null,
+ click: async () => {
+ if (options.unavailable || !submenu || !option) throw new Error("unavailable version");
+ version = option.version;
+ value = 0; submenu = false; actions.push(`selected:${version}`);
+ },
+ };
+ };
+ const menu = {
+ filter() { return this; }, last() { return this; }, isVisible: async () => true,
+ getByLabel: (name: RegExp) => {
+ if (!name.test(observedPicker.triggerLabel)) throw new Error("model trigger does not match observed label");
+ return versionTrigger;
+ },
+ getByRole: (role: string, args: { name: string | RegExp; exact: boolean }) => {
+ if (role !== "menuitemradio" || !args.exact) throw new Error("expected exact model radio");
+ return radio(args.name);
+ },
+ };
+ const composer = { locator: () => ({ locator: () => control }) };
+ const page = {
+ evaluate: async (fn: unknown, ids: string[]) => {
+ expect(ids).toEqual(["picker-value", "picker-instructions"]);
+ const descriptions = options.descriptionTexts ?? [
+ `${options.actualVersion ?? (value === 4 ? options.versionAtMax : undefined) ?? version} ${options.effortLabels?.[value] ?? (value === 4 ? "Pro" : "Instant")},第 ${value + 1} 项,共 ${options.max === undefined ? 5 : options.max + 1} 项。`,
+ observedPicker.descriptions["picker-instructions"],
+ ];
+ const previousDocument = (globalThis as any).document;
+ (globalThis as any).document = {
+ getElementById: (id: string) => {
+ const index = ids.indexOf(id);
+ return index < 0 ? null : { textContent: descriptions[index] };
+ },
+ };
+ try {
+ return (fn as (descriptionIds: string[]) => unknown)(ids);
+ } finally {
+ if (previousDocument === undefined) delete (globalThis as any).document;
+ else (globalThis as any).document = previousDocument;
+ }
+ },
+ locator: (selector: string) => selector === '[id="picker"]' || selector === CHATGPT_EFFORT_MENU_SELECTOR ? menu
+ : selector === CHATGPT_EFFORT_SLIDER_CONTAINER_SELECTOR ? container : hidden,
+ keyboard: { press: async () => {
+ if (keyboardFailure) throw new Error(keyboardFailure);
+ } },
+ isClosed: () => false,
+ };
+ const select = (ChatGptBrowserWorker.prototype as unknown as {
+ selectModelAndEffort(...args: unknown[]): Promise;
+ }).selectModelAndEffort;
+ return {
+ actions,
+ submittedStates,
+ state: () => ({ version, value }),
+ setEffort: (next: number) => { value = next; },
+ failKeyboardCleanup: (message = "keyboard cleanup failed") => { keyboardFailure = message; },
+ select: (requested: string | undefined, effort = "max", stageVersion?: string) => select.call(
+ { activeComposer: async () => composer }, page, "gpt-5.6-sol", effort,
+ { localToolsEnabled: false, solAvailable: true, proAvailable: true, },
+ undefined, stageVersion ?? (effort === "max" ? requested : undefined),
+ ),
+ send: async (requested: string | undefined, effort = "max", verifyEffortBeforeSend = false) => {
+ const send = (ChatGptBrowserWorker.prototype as unknown as {
+ sendAttachedPrompt(...args: unknown[]): Promise;
+ }).sendAttachedPrompt;
+ const form = { locator: () => control, getByTestId: () => ({
+ waitFor: async () => {}, isEnabled: async () => true,
+ press: async () => { actions.push("SEND"); submittedStates.push({ version, value }); },
+ }) };
+ return send.call({ activeComposer: async () => ({ locator: () => form }),
+ waitForSubmissionAcceptedWithRecovery: async () => "user_turn",
+ }, page, {}, undefined, undefined, undefined, undefined, undefined, undefined,
+ { modelVersion: requested, effort, uiEffortIndex: ({ low: 0, medium: 1, high: 2, xhigh: 3, max: 4 })[effort], verifyEffortBeforeSend });
+ },
+ };
+}
+
+test.each(["5.6", "5.5"])("explicit Pro version %s is selected before effort", async version => {
+ const fixture = picker({ initialVersion: "unselected" });
+ await fixture.select(version);
+ expect(fixture.state()).toEqual({ version, value: 4 });
+ expect(fixture.actions[0]).toBe("open-versions");
+ expect(fixture.actions[1]).toBe(`selected:${version}`);
+ expect(fixture.actions.slice(2)).toEqual(Array(4).fill(`${version}:ArrowRight`));
+});
+
+
+
+
+
+test("model-state verification is independent of aria-describedby order", async () => {
+ const fixture = picker({
+ initialVersion: "5.6",
+ descriptionTexts: [
+ observedPicker.descriptions["picker-instructions"],
+ "5.6 Pro,第 5 项,共 5 项。",
+ ],
+ });
+ await fixture.select("5.6");
+ expect(fixture.state()).toEqual({ version: "5.6", value: 4 });
+});
+
+test("a verified family survives the multipart effort change without reopening its model submenu", async () => {
+ const fixture = picker({
+ initialVersion: "5.6",
+ versionTriggerUnavailable: true,
+ effortLabels: ["Instant", "Medium", "High", "Extra High", "Pro"],
+ });
+ await fixture.select("5.6", "xhigh", "5.6");
+ expect(fixture.state()).toEqual({ version: "5.6", value: 3 });
+ expect(fixture.actions).toEqual(Array(3).fill("5.6:ArrowRight"));
+ await fixture.send("5.6", "xhigh", true);
+ expect(fixture.actions.filter(action => action === "SEND")).toHaveLength(1);
+});
+
+test("an unavailable family menu cannot bypass a mismatched pin", async () => {
+ const fixture = picker({ initialVersion: "6", versionTriggerUnavailable: true });
+ await expect(fixture.select("5.6", "xhigh", "5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).toEqual([]);
+});
+
+test("two low-effort stages and an Extra High summary retain 5.6 without a Pro send", async () => {
+ const fixture = picker({
+ initialVersion: "5.6", versionTriggerUnavailable: true,
+ effortLabels: ["Instant", "Medium", "High", "Extra High", "Pro"],
+ });
+ await fixture.select("5.6", "low", "5.6");
+ await fixture.send("5.6", "low", true);
+ await fixture.send("5.6", "low", true);
+ await fixture.select("5.6", "xhigh", "5.6");
+ await fixture.send("5.6", "xhigh", true);
+ expect(fixture.submittedStates).toEqual([
+ { version: "5.6", value: 0 }, { version: "5.6", value: 0 }, { version: "5.6", value: 3 },
+ ]);
+ expect(fixture.actions).not.toContain("open-versions");
+});
+
+test("an unrelated instruction mentioning Pro cannot validate an Instant state", async () => {
+ const fixture = picker({
+ descriptionTexts: [
+ "5.6 Instant,第 1 项,共 5 项。",
+ "Choose Pro for the most difficult tasks.",
+ ],
+ });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+});
+
+test.each([
+ ["5.6", "5.6 Instant,第 1 项,共 5 项。 Choose Pro for the most difficult tasks."],
+ ["5.6", "5.6 High, choose Pro for difficult tasks."],
+ ["5.6", "5.6 Pro is unavailable; choose another mode."],
+ ["5.6", "5.6 Pro Max,第 5 项,共 5 项。"],
+])("a non-Pro or unknown state cannot authorize pinned %s even at slider position 4: %s", async (version, state) => {
+ const fixture = picker({ descriptionTexts: [state] });
+ fixture.setEffort(4);
+ await expect(fixture.send(version)).rejects.toThrow(version);
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test.each([
+ ["5.6", "GPT-5.6 Sol Pro,第 5 项,共 5 项。"],
+ ["5.5", "GPT 5.5 Pro, item 5 of 5."],
+])("an exact pinned %s family and Pro state authorizes one send: %s", async (version, state) => {
+ const fixture = picker({ descriptionTexts: [state] });
+ fixture.setEffort(4);
+ await fixture.send(version);
+ expect(fixture.actions.filter(action => action === "SEND")).toHaveLength(1);
+});
+
+test("a missing version stops before any effort or submission can proceed", async () => {
+ const fixture = picker({ unavailable: true });
+ await expect(fixture.select("5.5")).rejects.toThrow("5.5");
+ expect(fixture.actions).toEqual(["open-versions"]);
+});
+
+test.each(["6", "7", "5.60", ""])("a mismatched or unverified live label (%s) cannot satisfy pinned 5.6", async actualVersion => {
+ const fixture = picker({ actualVersion });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+});
+
+
+
+test("a version reset during connector or file attachment prevents the send activation", async () => {
+ const fixture = picker({ actualVersion: "6" });
+ await expect(fixture.send("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("menu cleanup failure cannot mask a pre-send model mismatch", async () => {
+ const fixture = picker({ actualVersion: "6" });
+ fixture.failKeyboardCleanup();
+ await expect(fixture.send("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("menu cleanup failure still blocks a send after successful verification", async () => {
+ const fixture = picker();
+ await fixture.select("5.6");
+ fixture.failKeyboardCleanup();
+ await expect(fixture.send("5.6")).rejects.toThrow("keyboard cleanup failed");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("a verified 5.6 Pro selection permits one send", async () => {
+ const fixture = picker();
+ await fixture.select("5.6");
+ await fixture.send("5.6");
+ expect(fixture.actions.filter(action => action === "SEND")).toHaveLength(1);
+});
+
+// Observed in the installed zh-CN picker on 2026-09-12. Choosing 5.6 currently
+// exposes four levels; a default five-level picker can switch to 6 at Pro.
+const chineseEffortLabels = ["即时", "中", "高", "极高", "Pro"];
+
+test("localized intermediate effort names do not reject a verified 5.6 Pro", async () => {
+ const fixture = picker({ effortLabels: chineseEffortLabels });
+ await fixture.select("5.6");
+ await fixture.send("5.6");
+ expect(fixture.actions.filter(action => action === "SEND")).toHaveLength(1);
+});
+
+test.each(chineseEffortLabels.slice(0, 4))("Chinese effort %s cannot authorize a Pro send", async label => {
+ const fixture = picker({ descriptionTexts: [`5.6 ${label},第 5 项,共 5 项。请使用 Pro。`] });
+ fixture.setEffort(4);
+ await expect(fixture.send("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("a four-level localized pinned picker reports missing Pro without sending", async () => {
+ const fixture = picker({ max: 3, effortLabels: chineseEffortLabels });
+ await expect(fixture.select("5.6")).rejects.toThrow("only 4 effort levels for model 5.6");
+ expect(fixture.actions.filter(action => action.includes("ArrowRight"))).toHaveLength(0);
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("moving the slider to Pro must not silently switch a pinned 5.6 request to 6", async () => {
+ const fixture = picker({ effortLabels: chineseEffortLabels, versionAtMax: "6" });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions.filter(action => action.includes("ArrowRight"))).toHaveLength(4);
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("an effort reset to non-Pro prevents the send even when the version still matches", async () => {
+ const fixture = picker();
+ await fixture.select("5.6");
+ fixture.setEffort(2);
+ await expect(fixture.send("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("preparatory low-effort stages of pinned Pro stay on that version", async () => {
+ const fixture = picker();
+ await fixture.select("5.6", "low", "5.6");
+ expect(fixture.state()).toEqual({ version: "5.6", value: 0 });
+});
+
+test("an ordinary non-Pro route and a legacy unpinned route do not select a version", async () => {
+ const ordinary = picker();
+ await ordinary.select("5.6", "high");
+ expect(ordinary.state()).toEqual({ version: "6", value: 2 });
+ expect(ordinary.actions).not.toContain("open-versions");
+ const legacy = picker();
+ await legacy.select(undefined);
+ expect(legacy.state()).toEqual({ version: "6", value: 4 });
+ expect(legacy.actions).not.toContain("open-versions");
+});
+
+test("a four-level 5.6 picker supports an Extra High summary without entering Pro", async () => {
+ const fixture = picker({ max: 3, effortLabels: chineseEffortLabels });
+ await fixture.select("5.6", "xhigh", "5.6");
+ await fixture.send("5.6", "xhigh", true);
+ expect(fixture.state()).toEqual({ version: "5.6", value: 3 });
+ expect(fixture.actions.filter(action => action === "SEND")).toHaveLength(1);
+});
+
+test.each(["5.6 Pro,第 4 项,共 5 项。", "5.6 High,第 4 项,共 5 项。"])(
+ "strict Extra High rejects a conflicting spoken effort even at position 3: %s", async state => {
+ const fixture = picker({ descriptionTexts: [state] });
+ fixture.setEffort(3);
+ await expect(fixture.send("5.6", "xhigh", true)).rejects.toThrow();
+ expect(fixture.actions).not.toContain("SEND");
+ },
+);
+
+test("strict Extra High with no family pin still checks the live effort before send", async () => {
+ const fixture = picker({ descriptionTexts: ["6 Pro,第 5 项,共 5 项。"] });
+ fixture.setEffort(4);
+ await expect(fixture.send(undefined, "xhigh", true)).rejects.toThrow();
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("explicit compaction stages never upgrade an oversized record to Pro", () => {
+ const caps = { localToolsEnabled: false, solAvailable: true, proAvailable: true };
+ expect(resolveChatGptWebMultipartStagingMode("gpt-5.6-sol", caps, 100_000, 500_000, false).effort).toBe("low");
+ expect(resolveChatGptWebMultipartStagingMode("gpt-5.6-sol", caps, 100_000, 600_000, false).effort).toBe("medium");
+ expect(() => resolveChatGptWebMultipartStagingMode("gpt-5.6-sol", caps, 104_000, 1_200_000, false)).toThrow();
+ expect(resolveChatGptWebMultipartStagingMode("gpt-5.6-sol", caps, 104_000, 1_200_000).effort).toBe("max");
+});
+
+test.each(["6 Pro,第 5 项,共 5 项。", "5.6 Instant,第 1 项,共 5 项。"])(
+ "contradictory live state nodes prevent a pinned Pro send: %s", async conflictingState => {
+ const fixture = picker({ descriptionTexts: ["5.6 Pro,第 5 项,共 5 项。", conflictingState] });
+ fixture.setEffort(4);
+ await expect(fixture.send("5.6", "max", true)).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+ },
+);
+
+test("a correct spoken Pro label still cannot bypass a wrong numeric position", async () => {
+ const fixture = picker({ descriptionTexts: ["5.6 Pro,第 5 项,共 5 项。"] });
+ fixture.setEffort(3);
+ await expect(fixture.send("5.6", "max", true)).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("a missing numeric effort prevents send despite a correct spoken Pro state", async () => {
+ const fixture = picker({ numericValueMissing: true, descriptionTexts: ["5.6 Pro,第 5 项,共 5 项。"] });
+ await expect(fixture.send("5.6", "max", true)).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("missing state nodes cannot authorize a pinned Pro send", async () => {
+ const fixture = picker({ descriptionTexts: [] });
+ fixture.setEffort(4);
+ await expect(fixture.send("5.6", "max", true)).rejects.toThrow("5.6");
+ expect(fixture.actions).not.toContain("SEND");
+});
+
+test("model family overrides are explicit and reject unsupported models and versions", () => {
+ const caps = { localToolsEnabled: false, solAvailable: true, proAvailable: true };
+ expect(resolveChatGptWebModelMode("gpt-5.6-sol", "max", caps).modelVersion).toBeUndefined();
+ expect(resolveChatGptWebModelMode("gpt-5.6-sol", "low", caps, "5.5").modelVersion).toBe("5.5");
+ expect(() => resolveChatGptWebModelMode("gpt-5.6-sol", "max", caps, "6" as "5.6")).toThrow();
+ expect(() => resolveChatGptWebModelMode("gpt-5.6-luna", "low", { ...caps, solAvailable: false }, "5.5")).toThrow();
+});
+
+const runBrowserTurn = (ChatGptBrowserWorker.prototype as unknown as {
+ runBrowserTurn(turn: unknown): Promise;
+}).runBrowserTurn;
+const summaryTurn = {
+ modelId: "gpt-5.6-sol", reasoning: "max", compaction: true,
+ capabilities: { localToolsEnabled: false, solAvailable: true, proAvailable: true },
+ compactionExecution: { effort: "max", modelVersion: "5.6" },
+};
+
+test.each([
+ { compaction: false },
+ { reasoning: "high" },
+ { capabilities: { ...summaryTurn.capabilities, localToolsEnabled: true } },
+ { capabilities: { ...summaryTurn.capabilities, proAvailable: false } },
+ { modelId: "gpt-5.6-luna" },
+ { compactionExecution: { effort: "max" } },
+ { compactionExecution: { effort: "high" } },
+ { compactionExecution: { effort: "max", modelVersion: "6" } },
+ { reasoning: "xhigh", compactionExecution: { effort: "xhigh" } },
+ { reasoning: "xhigh", compactionExecution: { effort: "xhigh", modelVersion: "5.5" } },
+ { compactionExecution: { effort: "max", modelVersion: "5.6", extra: true } },
+ { compactionExecution: null },
+])("invalid explicit compaction fails before preparing any browser input: %j", async override => {
+ let prepared = false;
+ await expect(runBrowserTurn.call({}, { ...summaryTurn, ...override, prepare: async () => { prepared = true; } }))
+ .rejects.toThrow("Explicit compaction execution");
+ expect(prepared).toBe(false);
+});
+
+test.each([
+ { effort: "xhigh", modelVersion: "5.6" },
+ { effort: "max", modelVersion: "5.6" },
+ { effort: "max", modelVersion: "5.5" },
+])("the browser accepts a supported compaction execution: %j", async compactionExecution => {
+ let prepared = false;
+ await expect(runBrowserTurn.call({}, {
+ ...summaryTurn, reasoning: compactionExecution.effort, compactionExecution,
+ prepare: async () => { prepared = true; throw new Error("stop before browser access"); },
+ })).rejects.toThrow("stop before browser access");
+ expect(prepared).toBe(true);
+});
+
+test.each([false, true])("read-only compaction allows summary control connector=%s", async nativeConnector => {
+ let prepared = false;
+ await expect(runBrowserTurn.call({}, { ...summaryTurn, nativeConnector, prepare: async () => {
+ prepared = true;
+ throw new Error("stop before browser access");
+ } })).rejects.toThrow("stop before browser access");
+ expect(prepared).toBe(true);
+});
+
+test("an indivisible oversized compaction record fails before opening a browser or sending", async () => {
+ let browserOpened = false;
+ let released = false;
+ const record = JSON.stringify({ records: [{ kind: "message", message: { role: "user", content: " ".repeat(1_200_000) } }] });
+ await expect(runBrowserTurn.call({
+ config: { appName: "Fixture" },
+ runStage: async () => { browserOpened = true; throw new Error("unexpected browser access"); },
+ }, {
+ ...summaryTurn, traceId: "compaction-record-fixture",
+ prepare: async () => ({
+ text: "", images: [], multipart: { parts: [record, "{}"], commit: "Summarize the full record." },
+ release: () => { released = true; },
+ }),
+ })).rejects.toThrow("No ChatGPT effort available");
+ expect(browserOpened).toBe(false);
+ expect(released).toBe(true);
+}, 20_000);
diff --git a/tests/fixtures/pro-model-picker.json b/tests/fixtures/pro-model-picker.json
new file mode 100644
index 000000000..9f5af28d6
--- /dev/null
+++ b/tests/fixtures/pro-model-picker.json
@@ -0,0 +1,23 @@
+{
+ "observedAt": "2026-09-11",
+ "locale": "zh-CN",
+ "triggerLabel": "选择模型",
+ "options": [
+ { "role": "menuitemradio", "name": "最新", "version": "6" },
+ { "role": "menuitemradio", "name": "GPT-5.6 Sol", "version": "5.6" },
+ { "role": "menuitemradio", "name": "GPT-5.5", "version": "5.5" }
+ ],
+ "slider": {
+ "tag": "span", "role": "slider", "aria-hidden": "true",
+ "aria-valuemin": "0", "aria-valuemax": "4", "aria-valuenow": "4"
+ },
+ "keyboardControl": {
+ "tag": "div", "role": "menuitem", "aria-label": "能力",
+ "aria-keyshortcuts": "ArrowLeft ArrowRight",
+ "aria-describedby": "picker-value picker-instructions"
+ },
+ "descriptions": {
+ "picker-value": "5.6 Pro,第 5 项,共 5 项。",
+ "picker-instructions": "使用左右箭头键调整能力。"
+ }
+}
diff --git a/tests/launcher-helper-client.test.ts b/tests/launcher-helper-client.test.ts
index bf0ae189e..a056b2159 100644
--- a/tests/launcher-helper-client.test.ts
+++ b/tests/launcher-helper-client.test.ts
@@ -2,6 +2,9 @@ import { afterEach, expect, spyOn, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { spawn } from "node:child_process";
+import { createInterface } from "node:readline";
+import { once } from "node:events";
import { ChatGptCompactionHandoffAccepted, ChatGptWebAdapterError } from "../src/adapters/chatgpt-web/adapter-error";
import { LauncherBrowserHelperClient } from "../src/adapters/chatgpt-web/launcher-helper-client";
import type { BrowserTurn, ResolvedBrowserConfig } from "../src/adapters/chatgpt-web/browser-worker";
@@ -221,7 +224,7 @@ test("accepted compaction retires through the helper as completed without hiding
}
});
-test("launcher helper protocol preserves multipart context and the compaction flag", async () => {
+test("launcher helper protocol preserves multipart context and the compaction execution", async () => {
const sent: Record[] = [];
const client = new LauncherBrowserHelperClient({
appName: "Codex Native2 DEV",
@@ -243,6 +246,7 @@ test("launcher helper protocol preserves multipart context and the compaction fl
};
const child = {};
internal.child = child;
+ (internal as unknown as { helperFeatures: Set }).helperFeatures = new Set(["compaction-execution"]);
internal.ensureChild = async () => {};
internal.send = async message => {
sent.push(message);
@@ -266,9 +270,10 @@ test("launcher helper protocol preserves multipart context and the compaction fl
await expect(client.run({
traceId: "multipart-123",
modelId: "gpt-5.6-sol",
- reasoning: "high",
+ reasoning: "max",
capabilities: { localToolsEnabled: false, solAvailable: true, proAvailable: true },
compaction: true,
+ compactionExecution: { effort: "max", modelVersion: "5.5" },
prepare: async () => ({
text: "commit",
images: [],
@@ -283,6 +288,7 @@ test("launcher helper protocol preserves multipart context and the compaction fl
type: "run",
turn: {
compaction: true,
+ compactionExecution: { effort: "max", modelVersion: "5.5" },
},
});
expect(sent[1]).toMatchObject({
@@ -295,6 +301,130 @@ test("launcher helper protocol preserves multipart context and the compaction fl
});
});
+test("an old launcher helper rejects explicit compaction execution before dispatch", async () => {
+ const sent: unknown[] = [];
+ const client = new LauncherBrowserHelperClient({
+ appName: "Codex Native2",
+ browserHost: "launcher",
+ browserHostDescriptorPath: "/durable/launcher.json",
+ storageStatePath: "/durable/unused-state.json",
+ chromeExecutablePath: "/durable/unused-chrome",
+ headed: true,
+ autoApproveToolCalls: false,
+ });
+ const internal = client as unknown as {
+ ensureChild(): Promise;
+ send(message: unknown): Promise;
+ };
+ internal.ensureChild = async () => {};
+ internal.send = async message => { sent.push(message); };
+
+ const error = await client.run({
+ traceId: "old-helper-123",
+ modelId: "gpt-5.6-sol",
+ reasoning: "max",
+ capabilities: { localToolsEnabled: false, solAvailable: true, proAvailable: true },
+ compaction: true,
+ compactionExecution: { effort: "max", modelVersion: "5.6" },
+ prepare: async () => ({ text: "compact", images: [], release() {} }),
+ onTextDelta() {},
+ }).then(() => undefined, failure => failure);
+ expect(error).toBeInstanceOf(ChatGptWebAdapterError);
+ expect(error).toMatchObject({
+ message: expect.stringContaining("does not support compaction execution"),
+ status: 409,
+ errorType: "invalid_request_error",
+ code: "compaction_control_unavailable",
+ retryable: false,
+ });
+ expect(sent).toEqual([]);
+});
+
+test.each([
+ ["missing family", { reasoning: "max", compaction: true, compactionExecution: { effort: "max" } }],
+ ["mismatched effort", { reasoning: "xhigh", compaction: true, compactionExecution: { effort: "max", modelVersion: "5.6" } }],
+ ["ordinary turn", { reasoning: "max", compaction: false, compactionExecution: { effort: "max", modelVersion: "5.6" } }],
+ ["tool-capable turn", { reasoning: "max", compaction: true, compactionExecution: { effort: "max", modelVersion: "5.6" }, localToolsEnabled: true }],
+ ["unavailable Pro", { reasoning: "max", compaction: true, compactionExecution: { effort: "max", modelVersion: "5.6" }, proAvailable: false }],
+] as const)("daemon rejects invalid compaction execution: %s", async (_label, override) => {
+ const client = new LauncherBrowserHelperClient({
+ appName: "Codex Native2",
+ browserHost: "launcher",
+ browserHostDescriptorPath: "/durable/launcher.json",
+ storageStatePath: "/durable/unused-state.json",
+ chromeExecutablePath: "/durable/unused-chrome",
+ headed: true,
+ autoApproveToolCalls: false,
+ });
+ const internal = client as unknown as {
+ helperFeatures: Set;
+ ensureChild(): Promise;
+ send(message: unknown): Promise;
+ };
+ internal.helperFeatures = new Set(["compaction-execution"]);
+ internal.ensureChild = async () => {};
+ internal.send = async () => { throw new Error("invalid execution reached the wire"); };
+ const localToolsEnabled = "localToolsEnabled" in override ? override.localToolsEnabled : false;
+ const proAvailable = "proAvailable" in override ? override.proAvailable : true;
+
+ await expect(client.run({
+ traceId: `invalid-${_label.replaceAll(" ", "-")}`,
+ modelId: "gpt-5.6-sol",
+ reasoning: override.reasoning,
+ capabilities: { localToolsEnabled, solAvailable: true, proAvailable },
+ compaction: override.compaction,
+ compactionExecution: override.compactionExecution,
+ prepare: async () => ({ text: "compact", images: [], release() {} }),
+ onTextDelta() {},
+ } as BrowserTurn)).rejects.toThrow("compaction execution is invalid");
+});
+
+test("browser helper rejects a malformed compaction execution from raw IPC", async () => {
+ const child = spawn(process.execPath, [
+ new URL("../src/adapters/chatgpt-web/browser-helper-main.ts", import.meta.url).pathname,
+ ], {
+ env: {
+ ...process.env,
+ ELECTRON_RUN_AS_NODE: "1",
+ CODEX_CHATGPT_WEB_BROWSER_HELPER_PROCESS: "1",
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+ const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator]();
+ try {
+ const ready = JSON.parse((await lines.next()).value as string) as Record;
+ expect(ready).toMatchObject({ type: "ready" });
+ expect(ready.features).toContain("compaction-execution");
+ child.stdin.write(`${JSON.stringify({
+ type: "run",
+ id: "malformed-compaction-123",
+ config: {
+ appName: "Codex Native2",
+ browserHostDescriptorPath: "/durable/launcher.json",
+ turnTimeoutMs: 60_000,
+ autoApproveToolCalls: false,
+ },
+ turn: {
+ traceId: "malformed-compaction-123",
+ modelId: "gpt-5.6-sol",
+ reasoning: "max",
+ capabilities: { localToolsEnabled: false, solAvailable: true, proAvailable: true },
+ compaction: true,
+ compactionExecution: { effort: "max" },
+ },
+ })}\n`);
+ const rejected = JSON.parse((await lines.next()).value as string) as Record;
+ expect(rejected).toMatchObject({
+ type: "error",
+ id: "malformed-compaction-123",
+ message: "Browser helper compaction execution is invalid",
+ });
+ } finally {
+ child.stdin.end(`${JSON.stringify({ type: "shutdown" })}\n`);
+ await once(child, "exit");
+ }
+});
+
test("an abort dispatched during run submission cannot overtake the run frame", async () => {
const controller = new AbortController();
const messages: string[] = [];
diff --git a/tests/retained-compaction.test.ts b/tests/retained-compaction.test.ts
index b921b7b8d..9f3f38165 100644
--- a/tests/retained-compaction.test.ts
+++ b/tests/retained-compaction.test.ts
@@ -5,7 +5,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { BrowserTurn } from "../src/adapters/chatgpt-web/browser-worker";
import { ChatGptBrowserWorker } from "../src/adapters/chatgpt-web/browser-worker";
-import { ChatGptCompactionHandoffAccepted, chatGptRetainedConversationUnavailableError } from "../src/adapters/chatgpt-web/adapter-error";
+import {
+ ChatGptCompactionHandoffAccepted,
+ ChatGptWebAdapterError,
+ chatGptRetainedConversationUnavailableError,
+} from "../src/adapters/chatgpt-web/adapter-error";
import {
MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
cancelAllStructuredCompactions,
@@ -1014,7 +1018,16 @@ test("retained compaction can close its browser epoch while preserving an ordina
sessions.clear();
});
-test("adapter compact returns one same-agent handoff and preserves a pre-existing ordinary final", async () => {
+test.each([
+ [undefined, "max", undefined],
+ ["extra-high", "xhigh", { effort: "xhigh", modelVersion: "5.6" }],
+ ["5.6-pro", "max", { effort: "max", modelVersion: "5.6" }],
+ ["5.5-pro", "max", { effort: "max", modelVersion: "5.5" }],
+] as const)("retained Pro compaction dispatches policy %p without losing its source", async (
+ compactionModel,
+ reasoning,
+ execution,
+) => {
const root = mkdtempSync(join(shortSocketTempRoot(), "cgw-adapter-retained-compact-"));
const provider: CodexProviderConfig = {
adapter: "chatgpt-web",
@@ -1027,12 +1040,14 @@ test("adapter compact returns one same-agent handoff and preserves a pre-existin
localToolsEnabled: true,
solAvailable: true,
proAvailable: true,
+ ...(compactionModel ? { compactionModel } : {}),
},
};
const broker = TurnBroker.forSocket(provider.chatgptWeb!.brokerSocketPath!);
const worker = ChatGptBrowserWorker.forProvider(provider);
const originalRun = worker.run.bind(worker);
const sourceRequest = request(false);
+ sourceRequest.options.reasoning = "max";
const namespace = chatGptWebExecutionNamespace(provider);
const sourceKey = `${namespace}:${chatGptTurnExecutionKey(sourceRequest)}`;
const conversationKey = chatGptConversationKey(sourceRequest, namespace)!;
@@ -1055,6 +1070,9 @@ test("adapter compact returns one same-agent handoff and preserves a pre-existin
const binding = controlBinding(prepared.text);
expect(turn.nativeConnector).toBeTrue();
expect(turn.capabilities.localToolsEnabled).toBeFalse();
+ expect(turn.compaction).toBe(execution ? true : undefined);
+ expect(turn.reasoning).toBe(reasoning);
+ expect(turn.compactionExecution).toEqual(execution);
prepared.release();
await callTurnBroker(provider.chatgptWeb!.brokerSocketPath!, {
method: "submit_compaction_handoff",
@@ -1109,6 +1127,115 @@ test("adapter compact returns one same-agent handoff and preserves a pre-existin
}
});
+test("explicit compaction preserves an old-helper protocol failure before any prompt is accepted", async () => {
+ const root = mkdtempSync(join(shortSocketTempRoot(), "cgw-old-helper-compaction-"));
+ const provider: CodexProviderConfig = {
+ adapter: "chatgpt-web",
+ baseUrl: `browser://old-helper-compaction-${Date.now()}`,
+ chatgptWeb: {
+ browserHost: "launcher",
+ browserHostDescriptorPath: join(root, "launcher.json"),
+ browserDiagnosticsPath: join(root, "diagnostics"),
+ brokerSocketPath: defaultBrokerEndpoint(root),
+ localToolsEnabled: true,
+ solAvailable: true,
+ proAvailable: true,
+ compactionModel: "5.6-pro",
+ },
+ };
+ const worker = ChatGptBrowserWorker.forProvider(provider);
+ const originalRun = worker.run.bind(worker);
+ (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = async () => {
+ throw new ChatGptWebAdapterError(
+ "Launcher browser helper does not support compaction execution; update or restart the launcher",
+ {
+ status: 409,
+ errorType: "invalid_request_error",
+ code: "compaction_control_unavailable",
+ retryable: false,
+ },
+ );
+ };
+ const compact = request(true);
+ compact.options.reasoning = "max";
+ const events: AdapterEvent[] = [];
+ try {
+ await createChatGptWebAdapter(provider).runTurn!(
+ compact,
+ { headers: new Headers() },
+ event => events.push(event),
+ );
+ expect(events.filter(event => event.type === "error")).toEqual([{
+ type: "error",
+ message: "Launcher browser helper does not support compaction execution; update or restart the launcher",
+ status: 409,
+ errorType: "invalid_request_error",
+ code: "compaction_control_unavailable",
+ retryable: false,
+ }]);
+ expect(events.some(event => event.type === "done")).toBeFalse();
+ } finally {
+ (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = originalRun;
+ chatGptTurnSessions.clear();
+ await TurnBroker.forSocket(provider.chatgptWeb!.brokerSocketPath!).close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test.each([
+ ["extra-high", "xhigh", { effort: "xhigh", modelVersion: "5.6" }],
+ ["5.6-pro", "max", { effort: "max", modelVersion: "5.6" }],
+ ["5.5-pro", "max", { effort: "max", modelVersion: "5.5" }],
+] as const)("fresh browser-only Pro compaction dispatches %s", async (compactionModel, reasoning, execution) => {
+ const root = mkdtempSync(join(shortSocketTempRoot(), `cgw-read-only-${compactionModel}-`));
+ const provider: CodexProviderConfig = {
+ adapter: "chatgpt-web",
+ baseUrl: `browser://read-only-${compactionModel}-${Date.now()}`,
+ chatgptWeb: {
+ browserDiagnosticsPath: join(root, "diagnostics"),
+ brokerSocketPath: defaultBrokerEndpoint(root),
+ localToolsEnabled: false,
+ solAvailable: true,
+ proAvailable: true,
+ compactionModel,
+ },
+ };
+ const worker = ChatGptBrowserWorker.forProvider(provider);
+ const originalRun = worker.run.bind(worker);
+ let browserStarts = 0;
+ (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = async turn => {
+ browserStarts += 1;
+ expect(turn.reasoning).toBe(reasoning);
+ expect(turn.compaction).toBeTrue();
+ expect(turn.capabilities.localToolsEnabled).toBeFalse();
+ expect(turn.compactionExecution).toEqual(execution);
+ const prepared = await turn.prepare();
+ prepared.release();
+ turn.onTextDelta("Independent-model checkpoint");
+ return "Independent-model checkpoint";
+ };
+ const compact = request(true);
+ compact.options.reasoning = "max";
+ const events: AdapterEvent[] = [];
+ try {
+ await createChatGptWebAdapter(provider).runTurn!(
+ compact,
+ { headers: new Headers() },
+ event => events.push(event),
+ );
+ expect(compact.options.reasoning).toBe("max");
+ expect(browserStarts).toBe(1);
+ expect(events.some(event => event.type === "text_delta"
+ && event.text.includes("Independent-model checkpoint"))).toBeTrue();
+ expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "stop", endTurn: true });
+ } finally {
+ (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = originalRun;
+ chatGptTurnSessions.clear();
+ await TurnBroker.forSocket(provider.chatgptWeb!.brokerSocketPath!).close();
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("a compact HTTP observer can reconnect without sending a second retained-chat message", async () => {
const root = mkdtempSync(join(shortSocketTempRoot(), "cgw-compact-reconnect-"));
const provider: CodexProviderConfig = {
diff --git a/tests/runtime-layout.test.ts b/tests/runtime-layout.test.ts
index 570e139c3..894ca25ea 100644
--- a/tests/runtime-layout.test.ts
+++ b/tests/runtime-layout.test.ts
@@ -99,6 +99,36 @@ test("default setup uses the fixed production connector identities", () => {
expect(defaultConfig("full").subagentProtocol).toBe("compatibility-v1");
expect(defaultConfig("full").browserInteractionMode).toBe("automatic");
expect(defaultConfig("full").zeroRiskProEnabled).toBe(false);
+ expect(defaultConfig("full").compactionModel).toBeUndefined();
+});
+
+test.each(["extra-high", "5.6-pro", "5.5-pro"] as const)(
+ "configuration accepts and forwards the explicit %s compaction model",
+ compactionModel => {
+ const root = join(tmpdir(), `codex-chatgpt-web-compaction-model-${process.pid}-${compactionModel}`);
+ roots.push(root);
+ process.env.CODEX_CHATGPT_WEB_HOME = root;
+ mkdirSync(root, { recursive: true });
+ const config = { ...defaultConfig("browser-only"), compactionModel };
+ writeFileSync(join(root, "config.json"), `${JSON.stringify(config)}\n`);
+
+ const loaded = loadConfig();
+ expect(loaded.compactionModel).toBe(compactionModel);
+ expect(providerConfig(loaded).chatgptWeb?.compactionModel).toBe(compactionModel);
+ },
+);
+
+test("configuration rejects an unknown compaction model", () => {
+ const root = join(tmpdir(), `codex-chatgpt-web-compaction-model-invalid-${process.pid}`);
+ roots.push(root);
+ process.env.CODEX_CHATGPT_WEB_HOME = root;
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, "config.json"), `${JSON.stringify({
+ ...defaultConfig("browser-only"),
+ compactionModel: "latest-pro",
+ })}\n`);
+
+ expect(() => loadConfig()).toThrow("Invalid compactionModel");
});
test.each([
@@ -253,6 +283,7 @@ test("manual provider configuration preserves a distinct backend without guessin
config.browserInteractionMode = "manual";
config.solAvailable = true;
config.proAvailable = true;
+ config.compactionModel = "5.6-pro";
const provider = providerConfig(config);
expect(provider.models).toEqual([CHATGPT_WEB_ZERO_RISK_BACKEND_MODEL]);
@@ -267,6 +298,7 @@ test("manual provider configuration preserves a distinct backend without guessin
proAvailable: false,
experimentalBiggerContext: false,
});
+ expect(provider.chatgptWeb?.compactionModel).toBeUndefined();
config.zeroRiskProEnabled = true;
const proProvider = providerConfig(config);
diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts
index 07b209c51..c720ac3d0 100644
--- a/tests/server-compaction.test.ts
+++ b/tests/server-compaction.test.ts
@@ -98,6 +98,58 @@ test("compacts a Pro task with Pro effort", async () => {
expect(response.status).toBe(200);
});
+test("an eligible Pro compaction snapshots the live model preference once without changing normal turns", async () => {
+ const config = defaultConfig("browser-only");
+ config.proAvailable = true;
+ config.compactionModel = "5.5-pro";
+ let liveModel: "extra-high" | "5.6-pro" = "extra-high";
+ let reads = 0;
+ const options = {
+ rememberState: false,
+ readCompactionModel: () => {
+ reads += 1;
+ return liveModel;
+ },
+ };
+ let compactProvider: CodexProviderConfig | undefined;
+ const compact = await compactRequest(new Request("http://127.0.0.1/v1/responses/compact", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "chatgpt-web/pro",
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Compact" }] }],
+ }),
+ }), config, provider => {
+ compactProvider = structuredClone(provider);
+ liveModel = "5.6-pro";
+ return {
+ name: "live-compaction-model",
+ async runTurn(_parsed, _incoming, emit) {
+ expect(provider.chatgptWeb?.compactionModel).toBe("extra-high");
+ emit({ type: "text_delta", text: summary, phase: "final_answer" });
+ emit({ type: "done", stopReason: "stop", endTurn: true });
+ },
+ };
+ }, options);
+ expect(compact.status).toBe(200);
+ expect(reads).toBe(1);
+ expect(compactProvider?.chatgptWeb?.compactionModel).toBe("extra-high");
+ expect(config.compactionModel).toBe("5.5-pro");
+
+ const normal = await responseRequest(new Request("http://127.0.0.1/v1/responses", {
+ method: "POST",
+ body: JSON.stringify({ model: "chatgpt-web/pro", input: "Normal turn", stream: false }),
+ }), config, provider => ({
+ name: "normal-main-model",
+ async runTurn(_parsed, _incoming, emit) {
+ expect(provider.chatgptWeb?.compactionModel).toBe("5.5-pro");
+ emit({ type: "text_delta", text: "Normal response", phase: "final_answer" });
+ emit({ type: "done", stopReason: "stop", endTurn: true });
+ },
+ }), options);
+ expect(normal.status).toBe(200);
+ expect(reads).toBe(1);
+});
+
test("preserves canonical Codex turn metadata from the compact endpoint header", async () => {
const turnMetadata = { thread_id: "thread_compact", turn_id: "turn_compact" };
const response = await compactRequest(new Request("http://127.0.0.1:17841/v1/responses/compact", {
From 8fe8ffb9fe1ed419f09a28a93ec31cb630e02bca Mon Sep 17 00:00:00 2001
From: Julian <148066842+JulianZJN@users.noreply.github.com>
Date: Mon, 14 Sep 2026 02:40:53 +0800
Subject: [PATCH 2/4] refactor: tighten compaction model configuration
---
docs/compaction-model-selection.md | 101 +++++++++++++--------------
src/chatgpt-web-compaction-policy.ts | 23 +++---
src/compaction-model-config.ts | 33 ++++-----
3 files changed, 71 insertions(+), 86 deletions(-)
diff --git a/docs/compaction-model-selection.md b/docs/compaction-model-selection.md
index ed61af53e..04d79a6a5 100644
--- a/docs/compaction-model-selection.md
+++ b/docs/compaction-model-selection.md
@@ -1,71 +1,64 @@
# Optional model for Pro compaction
-**Settings → Pro compaction model** controls the summary step of an automatic
-ChatGPT Web Pro task. It does not change the model used for ordinary task turns.
+**Settings → Pro compaction model** changes only the summary generated when an
+automatic ChatGPT Web Pro task compacts its history. Ordinary task turns keep
+their original model.
| Choice | Summary execution |
| --- | --- |
-| Follow main task (default) | Existing compaction behavior, with no override |
-| GPT-5.6 Extra High | Explicit GPT-5.6 family at Extra High effort, not Pro |
-| GPT-5.6 Pro | Explicit GPT-5.6 family at Pro effort |
-| GPT-5.5 Pro | Explicit GPT-5.5 family at Pro effort |
+| Follow main task (default) | Existing behavior; no override |
+| GPT-5.6 Extra High | GPT-5.6 at Extra High effort |
+| GPT-5.6 Pro | GPT-5.6 at Pro effort |
+| GPT-5.5 Pro | GPT-5.5 at Pro effort |
-The setting is sampled when a new compaction starts. Saving a different choice
-does not interrupt an active turn or compaction, restart the service, or change
-Codex's model catalogue. Choosing the default removes the optional preference.
-Zero Risk, Luna, non-Pro tasks, and accounts without the required capabilities
-keep their existing behavior.
+The first request for a logical compaction pins the choice. Reconnects join that
+same run even if the setting changes meanwhile. A new choice applies to the next
+compaction; it does not interrupt active work, restart the service, or alter the
+Codex model catalogue. Zero Risk, Luna, non-Pro tasks, and accounts without the
+required controls keep their existing behavior.
-## Why choose a different summary model?
+## Behavior and limits
-Extra High avoids a Pro submission for the summary. Choosing an older Pro family
-still uses that family's Pro allowance; it is an alternative for users who want
-Pro for the checkpoint without selecting the same family as their main task.
-Availability, shared allowances and reset schedules depend on the account and
-ChatGPT's current rules. This is explicit model selection, not a quota bypass.
-See the [official model and usage-limit documentation](https://help.openai.com/en/articles/20001354-gpt-56-in-chatgpt).
+Extra High sends the summary at Extra High rather than Pro effort. The two fixed
+Pro choices send the summary with the selected Pro family. ChatGPT still controls
+which families and efforts are available, how usage is counted, and when limits
+reset. This setting does not bypass those controls.
-Reducing summary latency and avoiding some Pro-specific failure modes are goals,
-not guaranteed outcomes. Changing the summary model can change checkpoint
-quality. It does not repair browser disconnects, authentication failures, invalid
-handoffs, or an unavailable model.
+A different summary model can change checkpoint quality and latency. It does not
+repair an expired session, browser disconnect, invalid handoff, or unavailable
+model.
## Execution boundaries
-1. Codex decides when to compact and sends its canonical history and native turn
- identity to the bridge. This setting does not change context thresholds.
-2. The original request still identifies the retained source, shared compaction
- transaction, cancellation and retirement. Only the summary execution gets the
- selected model/effort override.
-3. The existing retained-source handoff is preserved. An automatic task without
- a usable retained source uses the existing read-only fresh-chat fallback.
- Browser-only summaries use the same execution preference. Summary generation
- does not gain ordinary Codex work tools.
-4. For an explicit override, multipart staging is restricted to non-Pro levels.
- An indivisible record that requires Pro's larger message envelope fails before
- sending; the bridge does not silently spend a Pro request on staging.
-5. Before submission, the live model controls must prove the requested effort
- and family. A missing family, changed
- control or unavailable effort causes an explicit error, not a fallback.
-6. The existing checkpoint validation and browser-release checks still run.
- Codex receives its replacement context and subsequent work uses the main
- task's original model.
-
-The five-minute structured handoff liveness budget is unchanged. Existing
-multipart progress acknowledgements can renew the relevant phase budget; this
-is not a new fifty-minute timeout or an automatic retry of an accepted prompt.
+1. Codex still decides when to compact. This setting does not change context or
+ compaction thresholds.
+2. Source lookup, transaction identity, cancellation, deduplication, and
+ retirement remain tied to the original Pro request. Only the summary execution
+ receives the selected family and effort.
+3. The retained-source handoff remains the primary path. If the retained source is
+ unavailable, the existing read-only fresh-chat fallback uses the same pinned
+ summary choice. Browser-only summaries follow the same rule and do not gain
+ ordinary Codex tools.
+4. Multipart preparation for an explicit override stays below Pro effort. If one
+ indivisible record needs Pro's larger message envelope, the request fails before
+ sending instead of spending an extra Pro message during staging.
+5. Immediately before submission, the bridge verifies the live family, effort,
+ and numeric picker position. Missing or contradictory state fails explicitly;
+ no other model is substituted.
+6. Existing checkpoint validation, browser cleanup, and the five-minute structured
+ handoff liveness budget are unchanged. An accepted prompt is not retried.
## Configuration and compatibility
-The optional core configuration field is `compactionModel`, with values
-`extra-high`, `5.6-pro`, or `5.5-pro`. An omitted field means follow-main.
-Use the launcher setting to persist it through the authenticated configuration
-command. The override is not part of the retained source's execution namespace.
+The optional core field is `compactionModel`, with values `extra-high`,
+`5.6-pro`, or `5.5-pro`. Omitting it means follow-main. The launcher writes the
+field through its authenticated control channel, and setup or application updates
+preserve the rest of the existing configuration.
-Both runtime and browser helper must support the `compaction-execution` feature.
-An older helper cannot silently ignore an explicit choice. Unknown settings and
-inconsistent execution payloads are rejected.
+The runtime and browser helper must both advertise `compaction-execution`. An old
+helper cannot ignore an explicit choice: the request fails before submission.
+Unknown settings and inconsistent execution payloads are rejected.
-The production service reads this preference for each new eligible compaction.
-The isolated DEV named-chat CLI snapshots its configuration at startup: reopen
-that CLI after changing the DEV preference. The launcher itself need not restart.
+The production service reads the preference for each new eligible compaction. The
+isolated DEV named-chat CLI snapshots configuration at startup, so reopen that CLI
+after changing the DEV preference. The launcher itself does not need to restart.
diff --git a/src/chatgpt-web-compaction-policy.ts b/src/chatgpt-web-compaction-policy.ts
index 0bb4b76d3..18db5caac 100644
--- a/src/chatgpt-web-compaction-policy.ts
+++ b/src/chatgpt-web-compaction-policy.ts
@@ -2,12 +2,18 @@ import { CHATGPT_WEB_BACKEND_MODEL } from "./chatgpt-web-models";
import type { ChatGptWebCapabilities } from "./adapters/chatgpt-web/model";
import type { CodexParsedRequest } from "./types";
-export type ChatGptWebCompactionModel = "extra-high" | "5.6-pro" | "5.5-pro";
-
export type ChatGptWebCompactionExecution =
| { effort: "xhigh"; modelVersion: "5.6" }
| { effort: "max"; modelVersion: "5.5" | "5.6" };
+const COMPACTION_EXECUTIONS = {
+ "extra-high": { effort: "xhigh", modelVersion: "5.6" },
+ "5.6-pro": { effort: "max", modelVersion: "5.6" },
+ "5.5-pro": { effort: "max", modelVersion: "5.5" },
+} as const satisfies Record;
+
+export type ChatGptWebCompactionModel = keyof typeof COMPACTION_EXECUTIONS;
+
export interface ChatGptWebCompactionPlan {
/**
* Request used only for summary generation. Source identity, retained lookup, cancellation, and
@@ -21,7 +27,9 @@ export function parseChatGptWebCompactionModel(
value: unknown,
): ChatGptWebCompactionModel | undefined {
if (value === undefined) return undefined;
- if (value === "extra-high" || value === "5.6-pro" || value === "5.5-pro") return value;
+ if (typeof value === "string" && Object.hasOwn(COMPACTION_EXECUTIONS, value)) {
+ return value as ChatGptWebCompactionModel;
+ }
throw new Error(
"Invalid compactionModel; expected extra-high, 5.6-pro, 5.5-pro, or an omitted value",
);
@@ -51,14 +59,7 @@ export function parseChatGptWebCompactionExecution(
}
function executionFor(model: ChatGptWebCompactionModel): ChatGptWebCompactionExecution {
- switch (model) {
- case "extra-high":
- return { effort: "xhigh", modelVersion: "5.6" };
- case "5.6-pro":
- return { effort: "max", modelVersion: "5.6" };
- case "5.5-pro":
- return { effort: "max", modelVersion: "5.5" };
- }
+ return { ...COMPACTION_EXECUTIONS[model] };
}
export function resolveChatGptWebCompactionPlan(
diff --git a/src/compaction-model-config.ts b/src/compaction-model-config.ts
index 0305f4e95..29d9a4409 100644
--- a/src/compaction-model-config.ts
+++ b/src/compaction-model-config.ts
@@ -4,7 +4,7 @@ import {
parseChatGptWebCompactionModel,
type ChatGptWebCompactionModel,
} from "./chatgpt-web-compaction-policy";
-import { loadConfig, saveConfig, type AppConfig } from "./config";
+import { loadConfig, saveConfig } from "./config";
import { readLauncherBrowserHostDescriptor } from "./launcher-browser-host";
export function authorizeLauncherControl(operation: string): string {
@@ -23,25 +23,17 @@ export function authorizeLauncherControl(operation: string): string {
return descriptorPath;
}
-function updateCompactionModel(
- config: AppConfig,
- model: ChatGptWebCompactionModel | undefined,
-): void {
- if (model === undefined) delete config.compactionModel;
- else config.compactionModel = model;
-}
-
export async function runCompactionModelConfigCommand(args: string[]): Promise {
- const action = args.shift();
- const rawModel = args.shift();
- const launcherControlIndex = args.indexOf("--launcher-control");
- const launcherControl = launcherControlIndex >= 0;
- if (launcherControl) args.splice(launcherControlIndex, 1);
- if (action !== "compaction-model" || !rawModel || args.length > 0) {
+ const [action, rawModel, ...rest] = args;
+ if (action !== "compaction-model" || !rawModel || rest.length > 1) {
throw new Error(
"Config command must be: config compaction-model --launcher-control",
);
}
+ if (rest[0] !== "--launcher-control") {
+ throw new Error("Compaction model must be changed through Codex Web GPT Settings");
+ }
+
let model: ChatGptWebCompactionModel | undefined;
if (rawModel !== "follow") {
try {
@@ -50,18 +42,17 @@ export async function runCompactionModelConfigCommand(args: string[]): Promise
Date: Mon, 14 Sep 2026 02:54:54 +0800
Subject: [PATCH 3/4] fix: clear dependency audit checks
---
bun.lock | 4 ++--
launcher/bun.lock | 2 +-
package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/bun.lock b/bun.lock
index 899853cc5..010bf4800 100644
--- a/bun.lock
+++ b/bun.lock
@@ -26,7 +26,7 @@
"overrides": {
"@hono/node-server": "2.0.12",
"fast-uri": "3.1.6",
- "hono": "4.12.34",
+ "hono": "4.13.5",
"qs": "6.16.0",
"zod-to-json-schema": "3.25.1",
},
@@ -127,7 +127,7 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
- "hono": ["hono@4.12.34", "", {}, "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA=="],
+ "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
diff --git a/launcher/bun.lock b/launcher/bun.lock
index e89aee1fe..361af2789 100644
--- a/launcher/bun.lock
+++ b/launcher/bun.lock
@@ -486,7 +486,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
- "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
+ "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
diff --git a/package.json b/package.json
index 8456912ea..f20b92acf 100644
--- a/package.json
+++ b/package.json
@@ -75,7 +75,7 @@
"overrides": {
"@hono/node-server": "2.0.12",
"fast-uri": "3.1.6",
- "hono": "4.12.34",
+ "hono": "4.13.5",
"qs": "6.16.0",
"zod-to-json-schema": "3.25.1"
},
From 1291cc4bb1d51499d06a3097cd545234c50dcabe Mon Sep 17 00:00:00 2001
From: Julian <148066842+JulianZJN@users.noreply.github.com>
Date: Mon, 14 Sep 2026 10:53:45 +0800
Subject: [PATCH 4/4] fix: pin compaction models through the owned expanded
picker
---
docs/compaction-model-selection.md | 27 ++++++
src/adapters/chatgpt-web/browser-worker.ts | 76 +++++++++------
tests/compaction-model-selection.test.ts | 100 ++++++++++++++++++--
tests/pro-model-picker-dom.test.ts | 104 +++++++++++++++++++++
4 files changed, 273 insertions(+), 34 deletions(-)
create mode 100644 tests/pro-model-picker-dom.test.ts
diff --git a/docs/compaction-model-selection.md b/docs/compaction-model-selection.md
index 04d79a6a5..60bf6c829 100644
--- a/docs/compaction-model-selection.md
+++ b/docs/compaction-model-selection.md
@@ -62,3 +62,30 @@ Unknown settings and inconsistent execution payloads are rejected.
The production service reads the preference for each new eligible compaction. The
isolated DEV named-chat CLI snapshots configuration at startup, so reopen that CLI
after changing the DEV preference. The launcher itself does not need to restart.
+
+## Picker-state regression checks
+
+The family list belongs to the composer-owned menu. Its collapsed advanced view can
+remain attached and geometrically visible while `inert`, so visibility alone does
+not prove that a model row is clickable. The selector expands a collapsed model
+trigger before a normal actionability-checked click. It reuses a family only when
+its unique exact model row is explicitly checked; a rendered 5.6 label under
+Latest is not proof that 5.6 is pinned.
+
+After changing the control, checked state and spoken family/effort each receive a
+bounded, read-only settling window of up to one second. This does not retry an
+accepted prompt or extend compaction's handoff budget. Immediate pre-send
+verification stays strict; unavailable or contradictory state still blocks sending.
+
+`tests/pro-model-picker-dom.test.ts` runs the actual selector against an offline
+HTML fixture in a fresh headless Chromium context, including inert rows and an
+unrelated duplicate model outside the owned menu. Network requests are blocked;
+it neither opens a logged-in profile nor submits a ChatGPT prompt. The tests are
+skipped unless an existing Chromium executable is explicitly supplied:
+
+```bash
+CHATGPT_PICKER_TEST_CHROME=/absolute/path/to/chrome bun test tests/pro-model-picker-dom.test.ts
+```
+
+These offline checks are not a substitute for an authenticated, installed
+integration test on each model.
diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts
index 5bb71a5bb..0100b6008 100644
--- a/src/adapters/chatgpt-web/browser-worker.ts
+++ b/src/adapters/chatgpt-web/browser-worker.ts
@@ -253,23 +253,28 @@ async function assertChatGptSelectedModelVersion(
version: ChatGptWebCompactionModelVersion | undefined,
requirePro = false,
expectedEffort?: ChatGptWebModelMode["effort"],
+ settleMs = 0,
): Promise {
// The numeric slider is aria-hidden. Its keyboard menuitem owns the live spoken
// version/effort through aria-describedby, not aria-valuetext on the slider.
- const keyboardControl = slider.locator("xpath=ancestor::*[@role='menuitem'][1]");
- const descriptionIds = (await keyboardControl.getAttribute("aria-describedby"))?.trim().split(/\s+/).filter(Boolean) ?? [];
- const descriptions = await page.evaluate(
- ids => ids
- .map(id => document.getElementById(id)?.textContent?.trim() ?? "")
- .filter(Boolean),
- descriptionIds,
- );
+ const deadline = Date.now() + settleMs;
+ for (;;) {
+ const keyboardControl = slider.locator("xpath=ancestor::*[@role='menuitem'][1]");
+ const descriptionIds = (await keyboardControl.getAttribute("aria-describedby"))?.trim().split(/\s+/).filter(Boolean) ?? [];
+ const descriptions = await page.evaluate(
+ ids => ids
+ .map(id => document.getElementById(id)?.textContent?.trim() ?? "")
+ .filter(Boolean),
+ descriptionIds,
+ );
+ if (chatGptModelStateMatches(descriptions, version, requirePro, expectedEffort)) return;
+ if (Date.now() >= deadline) break;
+ await new Promise(resolveSettle => setTimeout(resolveSettle, 50));
+ }
// Version and Pro must come from the same described state node: unrelated instructions
// mentioning Pro are not proof that the selected effort is actually Pro.
- if (!chatGptModelStateMatches(descriptions, version, requirePro, expectedEffort)) {
- if (version) throw chatGptPinnedModelError(version);
- throw chatGptModelControlUnavailableAdapterError("The live selector does not prove the requested summary effort");
- }
+ if (version) throw chatGptPinnedModelError(version);
+ throw chatGptModelControlUnavailableAdapterError("The live selector does not prove the requested summary effort");
}
export type ChatGptPersonalizationPreflight = "already-personalized" | "enabled";
@@ -2436,25 +2441,36 @@ export class ChatGptBrowserWorker {
const modelVersion = stageModelVersion ?? mode.modelVersion;
if (modelVersion) {
try {
- let familyVerified = false;
- try {
- await assertChatGptSelectedModelVersion(page, activation.slider, modelVersion);
- familyVerified = true;
- } catch (error) {
- if (!(error instanceof ChatGptWebAdapterError) || error.code !== "model_version_unavailable") throw error;
- }
- // Multipart staging changes effort, not family. An already verified family must not
- // depend on reopening a model submenu after prior messages have been accepted.
- // Missing/mismatched proof still requires exact selection and fresh verification.
- if (!familyVerified) {
- await activation.menu.getByLabel(/^(?:Select model|Choose model|选择模型|モデルを選択)$/).click({ timeout: 5_000 });
- const modelName = chatGptProModelOptionName(modelVersion);
- const option = activation.menu.getByRole("menuitemradio", { name: modelName, exact: true });
+ const modelName = chatGptProModelOptionName(modelVersion);
+ // A rendered 5.6 label is not a pin: Latest also renders 5.6 at lower efforts,
+ // then switches to 6 at Pro. Only the owned exact radio's checked state proves
+ // the selected family. Hidden attached radios remain authoritative for reads.
+ let option = activation.menu.getByRole("menuitemradio", { name: modelName, exact: true, includeHidden: true });
+ const optionCount = await option.count();
+ if (optionCount > 1) throw chatGptPinnedModelError(modelVersion);
+ const familyPinned = optionCount === 1 && await option.getAttribute("aria-checked") === "true";
+ if (!familyPinned) {
+ // Collapsed advanced-view rows still have visible geometry, but are inert.
+ // Respect the owned trigger's state before relying on row visibility.
+ const modelTrigger = activation.menu.getByLabel(/^(?:Select model|Choose model|选择模型|モデルを選択)$/);
+ const modelMenuCollapsed = await modelTrigger.count() === 1
+ && await modelTrigger.getAttribute("aria-expanded") === "false";
+ if (modelMenuCollapsed || !await option.isVisible().catch(() => false)) {
+ await modelTrigger.click({ timeout: 5_000 });
+ }
await option.waitFor({ state: "visible", timeout: 5_000 });
await option.click({ timeout: 5_000 });
await page.keyboard.press("Escape");
activation = await activateChatGptEffortMenu(page, currentEffort);
- await assertChatGptSelectedModelVersion(page, activation.slider, modelVersion);
+ option = activation.menu.getByRole("menuitemradio", { name: modelName, exact: true, includeHidden: true });
+ const pinDeadline = Date.now() + 1_000;
+ for (;;) {
+ const count = await option.count();
+ if (count > 1) throw chatGptPinnedModelError(modelVersion);
+ if (count === 1 && await option.getAttribute("aria-checked") === "true") break;
+ if (Date.now() >= pinDeadline) throw chatGptPinnedModelError(modelVersion);
+ await new Promise(resolveSettle => setTimeout(resolveSettle, 50));
+ }
}
} catch (error) {
throw chatGptPinnedModelError(modelVersion, error);
@@ -2544,7 +2560,11 @@ export class ChatGptBrowserWorker {
);
}
}
- if (modelVersion) await assertChatGptSelectedModelVersion(page, effortSlider, modelVersion, mode.effort === "max");
+ if (modelVersion) {
+ // React can update the numeric slider before its described state. Read until both
+ // the requested family and effort are proved; never re-click or send to repair it.
+ await assertChatGptSelectedModelVersion(page, effortSlider, modelVersion, mode.effort === "max", mode.effort, 1_000);
+ }
await captureDiagnostic?.("effort-selected");
await page.keyboard.press("Escape");
return mode;
diff --git a/tests/compaction-model-selection.test.ts b/tests/compaction-model-selection.test.ts
index 73fe8d685..1475130c3 100644
--- a/tests/compaction-model-selection.test.ts
+++ b/tests/compaction-model-selection.test.ts
@@ -20,8 +20,16 @@ function picker(options: {
initialVersion?: string;
versionTriggerUnavailable?: boolean;
numericValueMissing?: boolean;
+ checkedFamily?: string;
+ latestRoutesToSixAtPro?: boolean;
+ familyRowsVisible?: boolean;
+ familyRowsInertWhenCollapsed?: boolean;
+ descriptionLagReads?: number;
+ checkedStateLagReads?: number;
} = {}) {
let version = options.initialVersion ?? "6", value = 0, submenu = false;
+ let checkedFamily = options.checkedFamily ?? options.initialVersion ?? "6";
+ let descriptionLagReads = 0, checkedStateLagReads = 0;
let keyboardFailure: string | undefined;
const actions: string[] = [];
const submittedStates: Array<{ version: string; value: number }> = [];
@@ -37,6 +45,8 @@ function picker(options: {
press: async (key: string) => {
actions.push(`${version}:${key}`);
value += key === "ArrowRight" ? 1 : -1;
+ descriptionLagReads = options.descriptionLagReads ?? 0;
+ if (options.latestRoutesToSixAtPro && checkedFamily === "6") version = value === 4 ? "6" : "5.6";
} };
const slider = {
waitFor: async () => {},
@@ -57,6 +67,8 @@ function picker(options: {
};
const versionTrigger = {
count: async () => 1, waitFor: async () => {},
+ getAttribute: async (name: string) => name === "aria-expanded"
+ ? String(submenu || (options.familyRowsVisible === true && !options.familyRowsInertWhenCollapsed)) : null,
click: async () => {
if (options.versionTriggerUnavailable) throw new Error("family picker is unavailable in this menu state");
submenu = true; actions.push("open-versions");
@@ -64,13 +76,22 @@ function picker(options: {
};
const modelOptions = options.modelOptions ?? observedPicker.options;
const radio = (name: string | RegExp) => {
- const option = modelOptions.find(option => typeof name === "string" ? option.name === name : name.test(option.name));
+ const matches = modelOptions.filter(option => typeof name === "string" ? option.name === name : name.test(option.name));
+ const option = matches[0];
return {
- count: async () => options.unavailable ? 0 : 1,
+ isVisible: async () => !options.unavailable && !!option && (options.familyRowsVisible === true || submenu),
+ count: async () => options.unavailable ? 0 : matches.length,
waitFor: async () => { if (options.unavailable || !option) throw new Error("missing version"); },
- getAttribute: async (attribute: string) => attribute === "aria-checked" ? "false" : null,
+ getAttribute: async (attribute: string) => {
+ if (attribute !== "aria-checked") return null;
+ if (checkedStateLagReads-- > 0) return "false";
+ return String(checkedFamily === option?.version);
+ },
click: async () => {
- if (options.unavailable || !submenu || !option) throw new Error("unavailable version");
+ if (options.familyRowsInertWhenCollapsed && !submenu) throw new Error("model row is inert despite visible geometry");
+ if (options.unavailable || (!submenu && options.familyRowsVisible !== true) || !option) throw new Error("unavailable version");
+ checkedFamily = option.version;
+ checkedStateLagReads = options.checkedStateLagReads ?? 0;
version = option.version;
value = 0; submenu = false; actions.push(`selected:${version}`);
},
@@ -82,8 +103,9 @@ function picker(options: {
if (!name.test(observedPicker.triggerLabel)) throw new Error("model trigger does not match observed label");
return versionTrigger;
},
- getByRole: (role: string, args: { name: string | RegExp; exact: boolean }) => {
+ getByRole: (role: string, args: { name: string | RegExp; exact: boolean; includeHidden?: boolean }) => {
if (role !== "menuitemradio" || !args.exact) throw new Error("expected exact model radio");
+ if (!submenu && options.familyRowsVisible !== true && args.includeHidden !== true) return hidden;
return radio(args.name);
},
};
@@ -91,8 +113,9 @@ function picker(options: {
const page = {
evaluate: async (fn: unknown, ids: string[]) => {
expect(ids).toEqual(["picker-value", "picker-instructions"]);
+ const describedValue = descriptionLagReads-- > 0 ? Math.max(0, value - 1) : value;
const descriptions = options.descriptionTexts ?? [
- `${options.actualVersion ?? (value === 4 ? options.versionAtMax : undefined) ?? version} ${options.effortLabels?.[value] ?? (value === 4 ? "Pro" : "Instant")},第 ${value + 1} 项,共 ${options.max === undefined ? 5 : options.max + 1} 项。`,
+ `${options.actualVersion ?? (value === 4 ? options.versionAtMax : undefined) ?? version} ${options.effortLabels?.[describedValue] ?? (describedValue === 4 ? "Pro" : "Instant")},第 ${value + 1} 项,共 ${options.max === undefined ? 5 : options.max + 1} 项。`,
observedPicker.descriptions["picker-instructions"],
];
const previousDocument = (globalThis as any).document;
@@ -155,6 +178,71 @@ test.each(["5.6", "5.5"])("explicit Pro version %s is selected before effort", a
expect(fixture.actions.slice(2)).toEqual(Array(4).fill(`${version}:ArrowRight`));
});
+test.each([
+ ["5.6", "max", 4], ["5.5", "max", 4], ["5.6", "xhigh", 3],
+] as const)("Latest rendering 5.6 is explicitly pinned through inert rows for %s %s", async (version, effort, index) => {
+ const fixture = picker({
+ initialVersion: "5.6", checkedFamily: "6", latestRoutesToSixAtPro: true,
+ familyRowsVisible: true, familyRowsInertWhenCollapsed: true,
+ effortLabels: ["Instant", "Medium", "High", "Extra High", "Pro"],
+ });
+ await fixture.select(version, effort, version);
+ await fixture.send(version, effort, true);
+ expect(fixture.actions.slice(0, 2)).toEqual(["open-versions", `selected:${version}`]);
+ expect(fixture.submittedStates).toEqual([{ version, value: index }]);
+});
+
+test("an already-open actionable family list does not need its trigger clicked", async () => {
+ const fixture = picker({ familyRowsVisible: true, versionTriggerUnavailable: true });
+ await fixture.select("5.6");
+ expect(fixture.actions[0]).toBe("selected:5.6");
+ expect(fixture.actions).not.toContain("open-versions");
+});
+
+test("an exact checked hidden family is reused without reopening the inert list", async () => {
+ const fixture = picker({
+ initialVersion: "5.6", checkedFamily: "5.6", familyRowsInertWhenCollapsed: true,
+ versionTriggerUnavailable: true,
+ });
+ await fixture.select("5.6");
+ await fixture.send("5.6");
+ expect(fixture.actions).not.toContain("selected:5.6");
+ expect(fixture.actions).not.toContain("open-versions");
+});
+
+test("duplicate exact family rows cannot authorize selection or effort changes", async () => {
+ const fixture = picker({ initialVersion: "5.6", modelOptions: [...observedPicker.options,
+ { role: "menuitemradio", name: "GPT-5.6 Sol", version: "5.6" }],
+ });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).toEqual([]);
+});
+
+test("numeric effort may precede its described state by a bounded update", async () => {
+ const fixture = picker({ initialVersion: "5.6", descriptionLagReads: 2 });
+ await fixture.select("5.6");
+ await fixture.send("5.6");
+ expect(fixture.submittedStates).toEqual([{ version: "5.6", value: 4 }]);
+});
+
+test("freshly selected family checked state may settle before changing effort", async () => {
+ const fixture = picker({ checkedStateLagReads: 2 });
+ await fixture.select("5.6");
+ expect(fixture.state()).toEqual({ version: "5.6", value: 4 });
+});
+
+test("a described state that never settles still fails without sending", async () => {
+ const fixture = picker({ initialVersion: "5.6", descriptionLagReads: Infinity });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+ expect(fixture.submittedStates).toEqual([]);
+});
+
+test("an unchecked selected family cannot advance to effort selection", async () => {
+ const fixture = picker({ checkedStateLagReads: Infinity });
+ await expect(fixture.select("5.6")).rejects.toThrow("5.6");
+ expect(fixture.actions).toEqual(["open-versions", "selected:5.6"]);
+});
+
diff --git a/tests/pro-model-picker-dom.test.ts b/tests/pro-model-picker-dom.test.ts
new file mode 100644
index 000000000..4a7c1c054
--- /dev/null
+++ b/tests/pro-model-picker-dom.test.ts
@@ -0,0 +1,104 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { chromium, type Browser } from "playwright-core";
+import { ChatGptBrowserWorker } from "../src/adapters/chatgpt-web/browser-worker";
+
+// Opt in with an existing Chromium executable. This never connects to a user profile.
+const executablePath = process.env.CHATGPT_PICKER_TEST_CHROME;
+describe.skipIf(!executablePath)("offline model-picker DOM regression", () => {
+let browser: Browser;
+beforeAll(async () => {
+ browser = await chromium.launch({
+ executablePath: executablePath,
+ headless: true,
+ });
+}, 20_000);
+afterAll(async () => { await browser?.close(); }, 15_000);
+
+// Matches the relevant installed ChatGPT picker structure observed 2026-09-14:
+// the owned advanced view remains attached and geometrically visible while inert.
+// No account, network request, or ChatGPT prompt is involved in these DOM tests.
+async function fixture(initialFamily = "6") {
+ const context = await browser.newContext();
+ await context.route("**/*", route => route.abort());
+ const page = await context.newPage();
+ await page.setContent(`
+
+
+
Select model
+
+
+
+
Latest
+
GPT-5.6 Sol
+
GPT-5.5
+
+
+
Use arrow keys.
+
+
+ `);
+ const select = (ChatGptBrowserWorker.prototype as any).selectModelAndEffort;
+ return { page, context, select: (version: string, effort = "max") => select.call(
+ { activeComposer: async () => page.locator("#prompt-textarea") }, page, "gpt-5.6-sol", effort,
+ { localToolsEnabled: false, solAvailable: true, proAvailable: true },
+ undefined, version,
+ ) };
+}
+
+test("real DOM: geometrically visible inert model rows must be expanded before selection", async () => {
+ const f = await fixture();
+ try {
+ await f.page.locator('form button[aria-haspopup]').click();
+ const row = f.page.locator('#picker').getByRole('menuitemradio', { name: 'GPT-5.6 Sol', exact: true, includeHidden: true });
+ expect(await row.isVisible()).toBe(true);
+ expect(await row.evaluate(el => !!el.closest('[inert]'))).toBe(true);
+ await f.select('5.6');
+ expect(await f.page.locator('#live-state').textContent()).toBe('5.6 Pro, item 5 of 5.');
+ expect(await f.page.evaluate(() => (window as any).actions)).toEqual(['open-models', 'select:5.6']);
+ expect(await f.page.locator('#prompt-textarea').textContent()).toBe('');
+ } finally { await f.context.close(); }
+}, 15_000);
+
+test("real DOM: hidden checked family is reused for Extra High without opening or choosing again", async () => {
+ const f = await fixture('5.6');
+ try {
+ await f.select('5.6','xhigh');
+ expect(await f.page.locator('#live-state').textContent()).toBe('5.6 Extra High, item 4 of 5.');
+ expect(await f.page.evaluate(() => (window as any).actions)).toEqual([]);
+ } finally { await f.context.close(); }
+}, 15_000);
+
+});