From 4f31bb9bd0f30c2731cca532221657301a24f023 Mon Sep 17 00:00:00 2001 From: Jonathan Li <47408717+jonathanli12@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:06:41 -0700 Subject: [PATCH 01/11] feat(agents): keep ChatGPT-native models on v1 when the catalog is in v2 v2 child tasks from a ChatGPT-native parent arrive as backend-encrypted NEW_TASK bodies (#92). Routed children cannot read them. Routed parents send plaintext and can use v2. Add keepNativeChatGptOnV1, a switch inside v2 (not a fourth mode): Sol/Terra stay on v1 so they can still spawn Grok or Claude; every other catalog row stays v2. --- .../content/docs/guides/sub-agent-surface.md | 5 ++ .../docs/reference/configuration/agents.md | 3 +- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 4 +- gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Models.tsx | 49 +++++++++++++++++ gui/src/pages/models-shared.ts | 1 + src/cli/v2.ts | 34 +++++++++++- src/codex/catalog/parsing.ts | 43 ++++++++++++++- src/codex/catalog/sync.ts | 15 +++++- src/codex/convergence.ts | 2 + src/server/index.ts | 1 + .../management/agent-settings-routes.ts | 19 ++++++- src/types.ts | 5 ++ tests/multi-agent-keep-native-v1.test.ts | 52 +++++++++++++++++++ 20 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 tests/multi-agent-keep-native-v1.test.ts diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 32a689c560..2954ae37f8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -20,6 +20,11 @@ Choose the mode for **new sessions**. Existing sessions keep the surface they st | **base** (default) | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | | **v2** | Flat `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, and agent-list tools, with concurrent sessions. | Users who want the newer concurrent workflow and understand model inheritance and the encrypted-task limitation below. | +On **v2**, an optional **Keep ChatGPT on v1** switch (`keepNativeChatGptOnV1`) leaves Sol/Terra +on the v1 surface so they can still spawn Grok or Claude. ChatGPT-native parents encrypt v2 +`NEW_TASK` bodies; routed models cannot read them. Routed parents stay on v2, where child tasks +are plaintext. This is a switch *inside* v2, not a fourth catalog mode. + :::tip[Not sure?] Start with **base**. Choose **v1** when cross-provider delegation must work predictably. Force **v2** only when you specifically want its newer session model across every catalog entry. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 0b9ec7886d..fc9e33ca4b 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -11,6 +11,7 @@ routes, and limits delegated work. | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. Applies to new sessions. | +| `keepNativeChatGptOnV1?` | `boolean` | `false` | When `multiAgentMode` is `"v2"`, stamp ChatGPT-native rows (Sol/Terra and other ChatGPT-backend models) as v1. Routed parents stay on v2. Use this so a ChatGPT parent can still spawn Grok or Claude — native v2 child tasks are backend-encrypted ([#92](https://github.com/lidge-jun/opencodex/issues/92)). Ignored in `v1` and `default`. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids featured first in the sub-agent picker. The dashboard offers only bare native and routed ids and omits exact account-qualified choices when it saves; use `ocx agent subagents set` or edit the configuration for exact choices. An explicit empty list is preserved. | | `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | @@ -25,7 +26,7 @@ routes, and limits delegated work. | `agentTaskRecovery?` | `object` | — | Experimental opt-in recovery for backend-encrypted v2 tasks sent to routed providers. Disabled unless `enabled: true`; see [Encrypted v2 task recovery](#encrypted-v2-task-recovery). | Manage the surface with the dashboard or -`ocx v2 status|on|off|mode |threads |mode-hint `. +`ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a `config.json` key; `ocx v2 threads ` writes `max_concurrent_threads_per_session` under `[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3876fe7844..eb5d9fa336 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -503,6 +503,8 @@ export const de: Record = { "models.v2ModeDesc_v1": "Alle Modelle → v1-Oberfläche", "models.v2ModeDesc_default": "Upstream-Standard (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "Alle Modelle → v2-Oberfläche", + "models.keepNativeOnV1": "ChatGPT auf v1 lassen", + "models.keepNativeOnV1Hint": "ChatGPT-native Eltern verschlüsseln v2-Kindaufgaben — Grok und Claude können sie nicht lesen. An lassen, wenn Sol/Terra weiterhin geroutete Modelle starten sollen. Geroutete Eltern bleiben auf v2.", "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nÄnderungen gelten für neue Sitzungen.", "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 5773cf826f..d428b0d7e9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -528,7 +528,9 @@ export const en = { "models.v2ModeDesc_v1": "All models → v1 surface", "models.v2ModeDesc_default": "Upstream defaults (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "All models → v2 surface", - "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nChanges apply to new sessions.", + "models.keepNativeOnV1": "Keep ChatGPT on v1", + "models.keepNativeOnV1Hint": "ChatGPT-native parents encrypt v2 child tasks, so Grok and Claude cannot read them. Leave this on if Sol or Terra should still spawn routed models. Routed parents keep v2.", + "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nOn v2, Keep ChatGPT on v1 leaves Sol/Terra on the v1 surface so they can still spawn Grok or Claude. ChatGPT encrypts v2 child tasks; routed models cannot read them. Routed parents stay on v2.\n\nChanges apply to new sessions.", "dash.multiAgent": "Sub-agent", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3c55198ebd..87517d3ad9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -511,6 +511,8 @@ export const ja: Record = { "models.v2ModeDesc_v1": "すべてのモデル → v1 サーフェス", "models.v2ModeDesc_default": "上流のデフォルト(sol/terra=v2、luna=v1)", "models.v2ModeDesc_v2": "すべてのモデル → v2 サーフェス", + "models.keepNativeOnV1": "ChatGPT を v1 のまま", + "models.keepNativeOnV1Hint": "ChatGPT ネイティブの親は v2 子タスクを暗号化するため、Grok や Claude は読めません。Sol/Terra から routed モデルを spawn するならオンのまま。routed 親は v2 のままです。", "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\n変更は新規セッションに適用されます。", "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 09632c73e7..7b051048f0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -513,6 +513,8 @@ export const ko: Record = { "models.v2ModeDesc_v1": "전 모델 → v1 서피스", "models.v2ModeDesc_default": "업스트림 기본값 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "전 모델 → v2 서피스", + "models.keepNativeOnV1": "ChatGPT는 v1 유지", + "models.keepNativeOnV1Hint": "ChatGPT 네이티브 부모는 v2 자식 작업을 암호화해서 Grok/Claude가 읽지 못합니다. Sol/Terra가 routed 모델을 spawn해야 하면 켜 두세요. routed 부모는 v2를 유지합니다.", "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\n새 세션부터 적용됩니다.", "models.v2DocsLink": "v1 / v2가 뭔가요?", "dash.multiAgent": "서브에이전트", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index abcc924d3f..b5eb35088e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -516,6 +516,8 @@ export const ru: Record = { "models.v2ModeDesc_v1": "Все модели → поверхность v1", "models.v2ModeDesc_default": "Вышестоящие значения по умолчанию (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "Все модели → поверхность v2", + "models.keepNativeOnV1": "Оставить ChatGPT на v1", + "models.keepNativeOnV1Hint": "Нативные родители ChatGPT шифруют дочерние задачи v2 — Grok и Claude их не читают. Оставьте включённым, если Sol/Terra должны порождать routed-модели. Routed-родители остаются на v2.", "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nИзменения применяются к новым сессиям.", "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4a38a1fcde..ed4998dd15 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -519,6 +519,8 @@ export const tr: Record = { "models.v2ModeDesc_v1": "Tüm modeller → v1 yüzeyi", "models.v2ModeDesc_default": "Yukarı akış varsayılanları", "models.v2ModeDesc_v2": "Tüm modeller → v2 yüzeyi", + "models.keepNativeOnV1": "ChatGPT v1'de kalsın", + "models.keepNativeOnV1Hint": "ChatGPT yerel ebeveynleri v2 çocuk görevlerini şifreler; Grok ve Claude okuyamaz. Sol/Terra yönlendirilmiş modelleri spawn edecekse açık bırakın. Yönlendirilmiş ebeveynler v2'de kalır.", "models.v2Help": "v1 alt ajanları birincil modelle sınırlandırır; base standart ajan sınırlarını devralır; v2 tam çoklu ajan orkestrasyonunu etkinleştirir.", "dash.multiAgent": "Alt Ajan", "models.v2Conflict": "[agents] max_threads ayarlanmış — config.toml dosyasından kaldırın", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fa273129c2..769266e6c6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -395,6 +395,8 @@ export const zhTW: Record = { "models.v2ModeDesc_v1": "所有模型 → v1 介面", "models.v2ModeDesc_default": "上游預設值 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "所有模型 → v2 介面", + "models.keepNativeOnV1": "ChatGPT 維持 v1", + "models.keepNativeOnV1Hint": "ChatGPT 原生父代理會加密 v2 子任務,Grok/Claude 無法讀取。若 Sol/Terra 仍需派發路由模型,請保持開啟。路由父代理仍使用 v2。", "models.v2Help": "控制所有模型的多代理介面。\n\nv1: 經典單執行緒代理。所有模型使用 v1 協作介面。\nbase: 上游預設值 — sol/terra 使用 v2,luna 使用 v1,其餘跟隨 codex 功能標誌。\nv2: 多執行緒代理(spawn_agent)。所有模型使用 v2 協作介面。\n\n更改在新會話中生效。", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 將拒絕啟動,請從 config.toml 移除", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fe5366432a..3a7be58292 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -510,6 +510,8 @@ export const zh: Record = { "models.v2ModeDesc_v1": "所有模型 → v1 界面", "models.v2ModeDesc_default": "上游默认值 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "所有模型 → v2 界面", + "models.keepNativeOnV1": "ChatGPT 保持 v1", + "models.keepNativeOnV1Hint": "ChatGPT 原生父代理会加密 v2 子任务,Grok/Claude 无法读取。若 Sol/Terra 仍需派发路由模型,请保持开启。路由父代理仍使用 v2。", "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n更改在新会话中生效。", "models.v2DocsLink": "v1 / v2 是什么?", "dash.multiAgent": "子代理", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index f2fab8604a..27cd02a615 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -304,6 +304,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", + keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, }); } catch { setV2(null); // old server / network: hide the section instead of guessing @@ -802,6 +803,37 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }; + const setKeepNativeChatGptOnV1 = async (next: boolean) => { + if (!v2 || v2BusyRef.current) return; + if (v2.keepNativeChatGptOnV1 === next) return; + setV2Busy(true); + v2BusyRef.current = true; + setV2Note(""); + setStatus(""); + try { + const r = await fetch(`${apiBase}/api/v2`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keepNativeChatGptOnV1: next }), + }); + try { + const data = await readJsonOrThrow(r, t("models.saveFailed")); + void loadV2(); + setOk(true); + setStatus(t("models.v2Applied")); + setV2Note((data?.warnings ?? []).join(" ")); + } catch (e) { + setOk(false); + setStatus(e instanceof Error ? e.message : t("models.saveFailed")); + } + } catch { + setOk(false); setStatus(t("models.networkError")); + } finally { + setV2Busy(false); + v2BusyRef.current = false; + } + }; + const putV2Threads = async (value: number) => { // Same guards as the flag toggle: single-flight + server-side idempotence // (setMaxConcurrentThreads no-ops on equal value), so a re-selected current @@ -831,6 +863,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; agentsMaxThreadsConflict: data.agentsMaxThreadsConflict === true, maxConcurrentThreadsPerSession: typeof data.maxConcurrentThreadsPerSession === "number" ? data.maxConcurrentThreadsPerSession : null, multiAgentMode: data.multiAgentMode === "v1" || data.multiAgentMode === "v2" ? data.multiAgentMode : "default", + keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, }); setOk(true); setStatus(t("models.v2ThreadsApplied")); @@ -1292,6 +1325,22 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} + {v2 && v2.multiAgentMode === "v2" && ( +
+ + {t("models.keepNativeOnV1")}{" "} + + + + + void setKeepNativeChatGptOnV1(!v2.keepNativeChatGptOnV1)} + disabled={v2Busy} + label={t("models.keepNativeOnV1")} + /> +
+ )} {v2 && (v2.enabled || v2.agentsMaxThreadsConflict || v2Note) && ( diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index a8205616cb..368d9743b5 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -62,6 +62,7 @@ export interface V2Status { agentsMaxThreadsConflict: boolean; maxConcurrentThreadsPerSession?: number | null; multiAgentMode?: "v1" | "default" | "v2"; + keepNativeChatGptOnV1?: boolean; } export interface ShadowCallData { diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 9429eb204a..526dfeb670 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -110,6 +110,9 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () log.log(v2StatusLine(isEnabled())); const cfg = loadConfig(); log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default")); + log.log(cfg.keepNativeChatGptOnV1 === true + ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2" + : "keep_native_chatgpt_on_v1: OFF"); const threads = getLogicalMaxThreads(); log.log(`max_threads: ${threads ?? "(unset — codex default)"}`); const v2Active = isEnabled(); @@ -204,8 +207,37 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version."); return 0; } + if (verb === "keep-native-v1") { + const flag = (args[1] ?? "").trim().toLowerCase(); + if (flag !== "on" && flag !== "off") { + log.error("v2 keep-native-v1: expected on|off"); + return 1; + } + const cfg = loadConfig(); + const next = flag === "on"; + if (cfg.keepNativeChatGptOnV1 === true === next) { + log.log(next + ? "keep_native_chatgpt_on_v1 already ON — nothing to do." + : "keep_native_chatgpt_on_v1 already OFF — nothing to do."); + return 0; + } + if (next) cfg.keepNativeChatGptOnV1 = true; + else delete cfg.keepNativeChatGptOnV1; + saveConfig(cfg); + try { + const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex; + await sync(findPort ? await findPort() : undefined); + } catch (err) { + log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`); + return 1; + } + log.log(next + ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)." + : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions)."); + return 0; + } if (verb !== "on" && verb !== "off") { - log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode |threads |mode-hint )`); + log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode |keep-native-v1 |threads |mode-hint )`); return 1; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 705a0d2dbe..840d5e8abe 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -341,6 +341,32 @@ export function ensureStrictCatalogFields( export type MultiAgentMode = "v1" | "default" | "v2"; +export interface MultiAgentModeOptions { + /** + * When the catalog is in v2 mode, stamp ChatGPT-native rows as v1 instead. + * Routed parents get v2 (plaintext child tasks). Native Sol/Terra stay on v1 + * so they can still spawn Grok/Claude — ChatGPT encrypts v2 NEW_TASK bodies. + */ + keepNativeChatGptOnV1?: boolean; +} + +/** Catalog rows that run on the ChatGPT backend (encrypt v2 child tasks). */ +export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND) return true; + if (trustedAccountBoundNativeCatalogSlug(entry)) return true; + const routedNativeSlug = slug.startsWith(`${OPENAI_CODEX_PROVIDER_ID}/`) + ? slug.slice(OPENAI_CODEX_PROVIDER_ID.length + 1) + : ""; + if ( + entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND + && entry.use_responses_lite === true + && isNativeOpenAiCapabilityAliasModel(routedNativeSlug) + ) return true; + if (UPSTREAM_NATIVE_ENTRIES.has(slug) || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) return true; + return false; +} + export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { @@ -358,8 +384,23 @@ export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { * 260730_codex_rs_upstream_v2_live_handoff/060). Upstream pins are always * preserved: a genuine "v1" pin is a real capability statement and stays excluded. * With the feature off the output is byte-identical to the historical behavior. + * + * `keepNativeChatGptOnV1` only applies when `mode === "v2"`. It leaves Sol/Terra + * (and other ChatGPT-native rows) on v1 so a native parent can still spawn a + * routed child. See issue #92. */ -export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v2FeatureEnabled = false): RawEntry[] { +export function applyMultiAgentMode( + entries: RawEntry[], + mode: MultiAgentMode, + v2FeatureEnabled = false, + options: MultiAgentModeOptions = {}, +): RawEntry[] { + if (mode === "v2" && options.keepNativeChatGptOnV1 === true) { + for (const entry of entries) { + entry.multi_agent_version = catalogEntryIsNativeChatGpt(entry) ? "v1" : "v2"; + } + return entries; + } if (mode === "default") { // Restore upstream defaults: clear any stale forced multi_agent_version and // re-apply upstream pins from the snapshot for native entries that have one. diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 894d0959d3..36b529c881 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -390,6 +390,7 @@ export interface ObservedCatalogEntryBuildInput { readonly suppressedBareNativeSlugs: ReadonlySet; readonly disabledNativeAccountSlugs: ReadonlySet; readonly multiAgentV2Enabled: boolean; + readonly keepNativeChatGptOnV1?: boolean; readonly openaiContextCap?: number; /** Additional native ids to clone under account selectors, without creating bare rows. */ readonly accountNativeSlugs?: readonly string[]; @@ -412,6 +413,7 @@ export function buildCatalogEntries( contextCap?: number, accountNativeSlugs?: readonly string[], accountNativeSlugsBySelector?: ReadonlyMap, + keepNativeChatGptOnV1 = false, ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -425,6 +427,7 @@ export function buildCatalogEntries( suppressedBareNativeSlugs, disabledNativeAccountSlugs, multiAgentV2Enabled: isMultiAgentV2Enabled(), + keepNativeChatGptOnV1, openaiContextCap: contextCap, accountNativeSlugs, accountNativeSlugsBySelector, @@ -445,6 +448,7 @@ export function buildCatalogEntriesFromObservedState({ suppressedBareNativeSlugs, disabledNativeAccountSlugs, multiAgentV2Enabled, + keepNativeChatGptOnV1, openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, @@ -624,7 +628,9 @@ export function buildCatalogEntriesFromObservedState({ delete entry.prefer_websockets; } } - return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled); + return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, { + keepNativeChatGptOnV1, + }); } export function resetCatalogRuntimeStateForTests(): void { @@ -733,6 +739,7 @@ export interface ObservedCatalogMergeInput { readonly legacyCustomModelSlugs: ReadonlySet; readonly multiAgentMode: MultiAgentMode; readonly multiAgentV2Enabled: boolean; + readonly keepNativeChatGptOnV1?: boolean; readonly exactComboSlugs: ReadonlySet; readonly hasPhysicalComboProvider: boolean; readonly includeNativeOpenAi: boolean; @@ -763,6 +770,7 @@ export function mergeCatalogEntriesFromObservedState({ legacyCustomModelSlugs, multiAgentMode, multiAgentV2Enabled, + keepNativeChatGptOnV1, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, @@ -1088,6 +1096,7 @@ export function mergeCatalogEntriesFromObservedState({ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), multiAgentMode, multiAgentV2Enabled, + { keepNativeChatGptOnV1 }, ); for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; @@ -1125,6 +1134,7 @@ export function mergeCatalogEntriesForSync( )), ), openaiContextCap?: number, + keepNativeChatGptOnV1 = false, ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must // not suppress same-named native rows; actual admitted combo entries own that decision now. @@ -1154,6 +1164,7 @@ export function mergeCatalogEntriesForSync( legacyCustomModelSlugs, multiAgentMode, multiAgentV2Enabled: isMultiAgentV2Enabled(), + keepNativeChatGptOnV1, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, @@ -1477,6 +1488,7 @@ function writeRetainedCatalogSync({ suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, @@ -1497,6 +1509,7 @@ function writeRetainedCatalogSync({ legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index de35535f0a..6d501ef85d 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -279,6 +279,7 @@ function prepareCatalog( suppressedBareNativeSlugs, disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, accountNativeSlugs, accountNativeSlugsBySelector, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); @@ -305,6 +306,7 @@ function prepareCatalog( legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, multiAgentV2Enabled, + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, diff --git a/src/server/index.ts b/src/server/index.ts index 0ead1af63d..7241eea139 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -988,6 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server= 1" }, 400); } @@ -333,6 +339,14 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise saveConfigPreservingClaudeCode(config); warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`); } + if (wantsKeepNative) { + if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true; + else delete config.keepNativeChatGptOnV1; + saveConfigPreservingClaudeCode(config); + warnings.push(body.keepNativeChatGptOnV1 === true + ? "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions." + : "ChatGPT-native models follow the selected v1/v2/base surface. Applies to new sessions."); + } // New-key scalar writes: each writer is individually atomic, so apply them in // sequence after the transition. A failure here is a persistence failure (the // writers' ok:false result or a throw from the underlying atomic write helper), @@ -373,6 +387,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(), maxConcurrentThreadsPerSession: getLogicalMaxThreads(), multiAgentMode: config.multiAgentMode ?? "default", + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, agentsEnabled: getAgentsEnabled(), agentsMaxDepth: getAgentsMaxDepth(), subagentDeveloperInstructions: getSubagentDeveloperInstructions(), diff --git a/src/types.ts b/src/types.ts index a2771de0e5..3e69532b07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -825,6 +825,11 @@ export interface OcxConfig { * - "v2": force ALL models to v2 surface (override upstream pins) */ multiAgentMode?: "v1" | "default" | "v2"; + /** + * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1. + * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). + */ + keepNativeChatGptOnV1?: boolean; /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */ agentTaskRecovery?: { enabled?: boolean; diff --git a/tests/multi-agent-keep-native-v1.test.ts b/tests/multi-agent-keep-native-v1.test.ts new file mode 100644 index 0000000000..fda9d2b77d --- /dev/null +++ b/tests/multi-agent-keep-native-v1.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + applyMultiAgentMode, + catalogEntryIsNativeChatGpt, + type RawEntry, +} from "../src/codex/catalog/parsing"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../src/codex/catalog/kinds"; + +describe("keepNativeChatGptOnV1", () => { + test("v2 without the switch stamps every row v2", () => { + const entries: RawEntry[] = [ + { slug: "gpt-5.6-sol" }, + { slug: "xai/grok-4.6" }, + ]; + applyMultiAgentMode(entries, "v2"); + expect(entries[0]!.multi_agent_version).toBe("v2"); + expect(entries[1]!.multi_agent_version).toBe("v2"); + }); + + test("v2 + keepNativeChatGptOnV1 leaves ChatGPT-native on v1 and routed on v2", () => { + const entries: RawEntry[] = [ + { slug: "gpt-5.6-sol" }, + { slug: "gpt-5.6-terra" }, + { slug: "xai/grok-4.6" }, + { slug: "anthropic/claude-fable-5" }, + { slug: "combo/grok_4.6_fast_cursor_xai_fallback" }, + ]; + applyMultiAgentMode(entries, "v2", false, { keepNativeChatGptOnV1: true }); + expect(entries.find(e => e.slug === "gpt-5.6-sol")!.multi_agent_version).toBe("v1"); + expect(entries.find(e => e.slug === "gpt-5.6-terra")!.multi_agent_version).toBe("v1"); + expect(entries.find(e => e.slug === "xai/grok-4.6")!.multi_agent_version).toBe("v2"); + expect(entries.find(e => e.slug === "anthropic/claude-fable-5")!.multi_agent_version).toBe("v2"); + expect(entries.find(e => e.slug === "combo/grok_4.6_fast_cursor_xai_fallback")!.multi_agent_version).toBe("v2"); + }); + + test("the switch does nothing in v1 or default mode", () => { + const v1: RawEntry[] = [{ slug: "xai/grok-4.6" }]; + applyMultiAgentMode(v1, "v1", false, { keepNativeChatGptOnV1: true }); + expect(v1[0]!.multi_agent_version).toBe("v1"); + + const base: RawEntry[] = [{ slug: "xai/grok-4.6", multi_agent_version: "v1" }]; + applyMultiAgentMode(base, "default", false, { keepNativeChatGptOnV1: true }); + expect(base[0]!.multi_agent_version).toBeUndefined(); + }); + + test("native alias rows count as native; routed providers do not", () => { + const alias: RawEntry = { slug: "sol", opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND }; + expect(catalogEntryIsNativeChatGpt(alias)).toBe(true); + expect(catalogEntryIsNativeChatGpt({ slug: "xai/grok-4.6" })).toBe(false); + expect(catalogEntryIsNativeChatGpt({ slug: "gpt-5.6-sol" })).toBe(true); + }); +}); From b5604eaecef5a46efa515568920c2a3b3e7f9484 Mon Sep 17 00:00:00 2001 From: Jonathan Li <47408717+jonathanli12@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:29:14 -0700 Subject: [PATCH 02/11] fix(agents): keep routed native aliases on v2 and address review nits combo-native-alias rows are OpenCodex-routed, so they stay on v2 unless they still carry the ChatGPT-forward contract. Repeat keep-native-v1 commands re-sync the catalog, and the docs/i18n/warning text now match the mixed-surface policy. --- .../content/docs/guides/sub-agent-surface.md | 2 +- .../docs/reference/configuration/agents.md | 2 +- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 4 ++-- src/cli/v2.ts | 13 +++++++------ src/codex/catalog/parsing.ts | 7 ++++++- src/server/management/agent-settings-routes.ts | 5 ++++- tests/multi-agent-keep-native-v1.test.ts | 17 +++++++++++++++-- 14 files changed, 43 insertions(+), 21 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 2954ae37f8..6d09880fc6 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -36,7 +36,7 @@ The selected mode controls the `multi_agent_version` field in every catalog entr - **v1** stamps `multi_agent_version = "v1"` on every model. - **base** restores upstream pins. Unpinned entries follow the native `multi_agent_v2` feature flag. -- **v2** stamps `multi_agent_version = "v2"` on every model. +- **v2** stamps `multi_agent_version = "v2"` on every model, except when **Keep ChatGPT on v1** is enabled: ChatGPT-native rows stay `"v1"` and routed or combo rows stay `"v2"`. opencodex applies this as the final pass to both the live `/v1/models` catalog and the catalog synced to disk. That is why a mode change affects newly created App, CLI, and TUI sessions consistently. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index fc9e33ca4b..ddf93a2d47 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -26,7 +26,7 @@ routes, and limits delegated work. | `agentTaskRecovery?` | `object` | — | Experimental opt-in recovery for backend-encrypted v2 tasks sent to routed providers. Disabled unless `enabled: true`; see [Encrypted v2 task recovery](#encrypted-v2-task-recovery). | Manage the surface with the dashboard or -`ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. +`ocx v2 status|on|off|mode |keep-native-v1 |threads |mode-hint `. Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a `config.json` key; `ocx v2 threads ` writes `max_concurrent_threads_per_session` under `[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index eb5d9fa336..a4a681d9b7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -505,7 +505,7 @@ export const de: Record = { "models.v2ModeDesc_v2": "Alle Modelle → v2-Oberfläche", "models.keepNativeOnV1": "ChatGPT auf v1 lassen", "models.keepNativeOnV1Hint": "ChatGPT-native Eltern verschlüsseln v2-Kindaufgaben — Grok und Claude können sie nicht lesen. An lassen, wenn Sol/Terra weiterhin geroutete Modelle starten sollen. Geroutete Eltern bleiben auf v2.", - "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nÄnderungen gelten für neue Sitzungen.", + "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nUnter v2 lässt „ChatGPT auf v1 lassen“ Sol/Terra auf v1, damit sie weiter Grok oder Claude starten können. ChatGPT verschlüsselt v2-Kindaufgaben; geroutete Modelle können sie nicht lesen. Geroutete Eltern bleiben auf v2.\n\nÄnderungen gelten für neue Sitzungen.", "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", "models.v2Applied": "Sub-Agent-Modus aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d428b0d7e9..6ed131e19c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -529,7 +529,7 @@ export const en = { "models.v2ModeDesc_default": "Upstream defaults (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "All models → v2 surface", "models.keepNativeOnV1": "Keep ChatGPT on v1", - "models.keepNativeOnV1Hint": "ChatGPT-native parents encrypt v2 child tasks, so Grok and Claude cannot read them. Leave this on if Sol or Terra should still spawn routed models. Routed parents keep v2.", + "models.keepNativeOnV1Hint": "ChatGPT encrypts v2 child tasks only when a ChatGPT-native parent stays on v2, so Grok and Claude cannot read them. Turn this on to keep Sol/Terra on v1 and avoid that encryption. Routed parents keep v2.", "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nOn v2, Keep ChatGPT on v1 leaves Sol/Terra on the v1 surface so they can still spawn Grok or Claude. ChatGPT encrypts v2 child tasks; routed models cannot read them. Routed parents stay on v2.\n\nChanges apply to new sessions.", "dash.multiAgent": "Sub-agent", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 87517d3ad9..10f91145f2 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -513,7 +513,7 @@ export const ja: Record = { "models.v2ModeDesc_v2": "すべてのモデル → v2 サーフェス", "models.keepNativeOnV1": "ChatGPT を v1 のまま", "models.keepNativeOnV1Hint": "ChatGPT ネイティブの親は v2 子タスクを暗号化するため、Grok や Claude は読めません。Sol/Terra から routed モデルを spawn するならオンのまま。routed 親は v2 のままです。", - "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\n変更は新規セッションに適用されます。", + "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\nv2 では「ChatGPT を v1 のまま」にすると Sol/Terra が v1 に留まり、Grok や Claude を spawn できます。ChatGPT は v2 子タスクを暗号化するため routed モデルは読めません。routed 親は v2 のままです。\n\n変更は新規セッションに適用されます。", "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", "models.v2Applied": "サブエージェントモードを更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7b051048f0..b19fcad3e4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -515,7 +515,7 @@ export const ko: Record = { "models.v2ModeDesc_v2": "전 모델 → v2 서피스", "models.keepNativeOnV1": "ChatGPT는 v1 유지", "models.keepNativeOnV1Hint": "ChatGPT 네이티브 부모는 v2 자식 작업을 암호화해서 Grok/Claude가 읽지 못합니다. Sol/Terra가 routed 모델을 spawn해야 하면 켜 두세요. routed 부모는 v2를 유지합니다.", - "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\n새 세션부터 적용됩니다.", + "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\nv2에서 ChatGPT는 v1 유지를 켜면 Sol/Terra가 v1에 남아 Grok이나 Claude를 spawn할 수 있습니다. ChatGPT는 v2 자식 작업을 암호화하므로 routed 모델은 읽지 못합니다. routed 부모는 v2를 유지합니다.\n\n새 세션부터 적용됩니다.", "models.v2DocsLink": "v1 / v2가 뭔가요?", "dash.multiAgent": "서브에이전트", "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b5eb35088e..252de8396c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -518,7 +518,7 @@ export const ru: Record = { "models.v2ModeDesc_v2": "Все модели → поверхность v2", "models.keepNativeOnV1": "Оставить ChatGPT на v1", "models.keepNativeOnV1Hint": "Нативные родители ChatGPT шифруют дочерние задачи v2 — Grok и Claude их не читают. Оставьте включённым, если Sol/Terra должны порождать routed-модели. Routed-родители остаются на v2.", - "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nИзменения применяются к новым сессиям.", + "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nНа v2 «Оставить ChatGPT на v1» оставляет Sol/Terra на v1, чтобы они могли порождать Grok или Claude. ChatGPT шифрует дочерние задачи v2 — routed-модели их не читают. Routed-родители остаются на v2.\n\nИзменения применяются к новым сессиям.", "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", "models.v2Applied": "Режим подагента обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ed4998dd15..49c70490ca 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -521,7 +521,7 @@ export const tr: Record = { "models.v2ModeDesc_v2": "Tüm modeller → v2 yüzeyi", "models.keepNativeOnV1": "ChatGPT v1'de kalsın", "models.keepNativeOnV1Hint": "ChatGPT yerel ebeveynleri v2 çocuk görevlerini şifreler; Grok ve Claude okuyamaz. Sol/Terra yönlendirilmiş modelleri spawn edecekse açık bırakın. Yönlendirilmiş ebeveynler v2'de kalır.", - "models.v2Help": "v1 alt ajanları birincil modelle sınırlandırır; base standart ajan sınırlarını devralır; v2 tam çoklu ajan orkestrasyonunu etkinleştirir.", + "models.v2Help": "v1 alt ajanları birincil modelle sınırlandırır; base standart ajan sınırlarını devralır; v2 tam çoklu ajan orkestrasyonunu etkinleştirir.\n\nv2'de ChatGPT v1'de kalsın, Sol/Terra'yı v1'de bırakır; böylece Grok veya Claude spawn edebilirler. ChatGPT v2 çocuk görevlerini şifreler; yönlendirilmiş modeller okuyamaz. Yönlendirilmiş ebeveynler v2'de kalır.\n\nDeğişiklikler yeni oturumlara uygulanır.", "dash.multiAgent": "Alt Ajan", "models.v2Conflict": "[agents] max_threads ayarlanmış — config.toml dosyasından kaldırın", "models.v2Applied": "Çoklu ajan modu güncellendi. Yeni oturumlar bu modu kullanacaktır; model seçiciyi yenilemek için Codex uygulamasını yeniden başlatın.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 769266e6c6..9dd4ef7b69 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -397,7 +397,7 @@ export const zhTW: Record = { "models.v2ModeDesc_v2": "所有模型 → v2 介面", "models.keepNativeOnV1": "ChatGPT 維持 v1", "models.keepNativeOnV1Hint": "ChatGPT 原生父代理會加密 v2 子任務,Grok/Claude 無法讀取。若 Sol/Terra 仍需派發路由模型,請保持開啟。路由父代理仍使用 v2。", - "models.v2Help": "控制所有模型的多代理介面。\n\nv1: 經典單執行緒代理。所有模型使用 v1 協作介面。\nbase: 上游預設值 — sol/terra 使用 v2,luna 使用 v1,其餘跟隨 codex 功能標誌。\nv2: 多執行緒代理(spawn_agent)。所有模型使用 v2 協作介面。\n\n更改在新會話中生效。", + "models.v2Help": "控制所有模型的多代理介面。\n\nv1: 經典單執行緒代理。所有模型使用 v1 協作介面。\nbase: 上游預設值 — sol/terra 使用 v2,luna 使用 v1,其餘跟隨 codex 功能標誌。\nv2: 多執行緒代理(spawn_agent)。所有模型使用 v2 協作介面。\n\n在 v2 下,「ChatGPT 維持 v1」會讓 Sol/Terra 留在 v1,以便繼續派發 Grok 或 Claude。ChatGPT 會加密 v2 子任務,路由模型無法讀取;路由父代理仍留在 v2。\n\n更改在新會話中生效。", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 將拒絕啟動,請從 config.toml 移除", "models.v2Applied": "子代理模式已更新 — 新會話生效(重新啟動 Codex 應用以重新整理選擇器)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3a7be58292..260b6a233f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -511,8 +511,8 @@ export const zh: Record = { "models.v2ModeDesc_default": "上游默认值 (sol/terra=v2, luna=v1)", "models.v2ModeDesc_v2": "所有模型 → v2 界面", "models.keepNativeOnV1": "ChatGPT 保持 v1", - "models.keepNativeOnV1Hint": "ChatGPT 原生父代理会加密 v2 子任务,Grok/Claude 无法读取。若 Sol/Terra 仍需派发路由模型,请保持开启。路由父代理仍使用 v2。", - "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n更改在新会话中生效。", + "models.keepNativeOnV1Hint": "仅当 ChatGPT 原生父代理仍留在 v2 时,才会加密 v2 子任务,Grok/Claude 无法读取。开启此选项可让 Sol/Terra 留在 v1,从而避免该加密。路由父代理仍使用 v2。", + "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n在 v2 下,「ChatGPT 保持 v1」会让 Sol/Terra 留在 v1,以便继续派发 Grok 或 Claude。ChatGPT 会加密 v2 子任务,路由模型无法读取;路由父代理仍留在 v2。\n\n更改在新会话中生效。", "models.v2DocsLink": "v1 / v2 是什么?", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 526dfeb670..9071fa183c 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -215,12 +215,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () } const cfg = loadConfig(); const next = flag === "on"; - if (cfg.keepNativeChatGptOnV1 === true === next) { - log.log(next - ? "keep_native_chatgpt_on_v1 already ON — nothing to do." - : "keep_native_chatgpt_on_v1 already OFF — nothing to do."); - return 0; - } + const already = cfg.keepNativeChatGptOnV1 === true === next; if (next) cfg.keepNativeChatGptOnV1 = true; else delete cfg.keepNativeChatGptOnV1; saveConfig(cfg); @@ -231,6 +226,12 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: () log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`); return 1; } + if (already) { + log.log(next + ? "keep_native_chatgpt_on_v1 already ON — catalog re-synced." + : "keep_native_chatgpt_on_v1 already OFF — catalog re-synced."); + return 0; + } log.log(next ? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)." : "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions)."); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 840d5e8abe..08cf1e8989 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -353,7 +353,12 @@ export interface MultiAgentModeOptions { /** Catalog rows that run on the ChatGPT backend (encrypt v2 child tasks). */ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean { const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND) return true; + // combo-native-alias-v1 occupies a bare native slug but is routed through + // OpenCodex. Keep those on v2 unless the row still carries the ChatGPT-forward + // contract (`use_responses_lite`). + if (entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND) { + return entry.use_responses_lite === true; + } if (trustedAccountBoundNativeCatalogSlug(entry)) return true; const routedNativeSlug = slug.startsWith(`${OPENAI_CODEX_PROVIDER_ID}/`) ? slug.slice(OPENAI_CODEX_PROVIDER_ID.length + 1) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 29d31cf2b3..dbd8088c28 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -343,8 +343,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true; else delete config.keepNativeChatGptOnV1; saveConfigPreservingClaudeCode(config); + const effectiveMode = mode ?? config.multiAgentMode ?? "default"; warnings.push(body.keepNativeChatGptOnV1 === true - ? "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions." + ? (effectiveMode === "v2" + ? "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions." + : "keepNativeChatGptOnV1 is stored but inactive until multi-agent mode is v2. Applies to new sessions.") : "ChatGPT-native models follow the selected v1/v2/base surface. Applies to new sessions."); } // New-key scalar writes: each writer is individually atomic, so apply them in diff --git a/tests/multi-agent-keep-native-v1.test.ts b/tests/multi-agent-keep-native-v1.test.ts index fda9d2b77d..74d2df17ea 100644 --- a/tests/multi-agent-keep-native-v1.test.ts +++ b/tests/multi-agent-keep-native-v1.test.ts @@ -44,9 +44,22 @@ describe("keepNativeChatGptOnV1", () => { }); test("native alias rows count as native; routed providers do not", () => { - const alias: RawEntry = { slug: "sol", opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND }; - expect(catalogEntryIsNativeChatGpt(alias)).toBe(true); + const routedAlias: RawEntry = { slug: "sol", opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND }; + expect(catalogEntryIsNativeChatGpt(routedAlias)).toBe(false); + expect(catalogEntryIsNativeChatGpt({ + slug: "sol", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + use_responses_lite: true, + })).toBe(true); expect(catalogEntryIsNativeChatGpt({ slug: "xai/grok-4.6" })).toBe(false); expect(catalogEntryIsNativeChatGpt({ slug: "gpt-5.6-sol" })).toBe(true); + + const stamped: RawEntry[] = [ + { slug: "sol", opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND }, + { slug: "gpt-5.6-sol" }, + ]; + applyMultiAgentMode(stamped, "v2", false, { keepNativeChatGptOnV1: true }); + expect(stamped[0]!.multi_agent_version).toBe("v2"); + expect(stamped[1]!.multi_agent_version).toBe("v1"); }); }); From 12b4363702b8a6368e40175794ad9a077eb3f2b0 Mon Sep 17 00:00:00 2001 From: Jonathan Li <47408717+jonathanli12@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:07:10 -0700 Subject: [PATCH 03/11] style(gui): anchor the keep-native-v1 switch under the Sub-agent control The switch rendered in a second .models-v2-mode-row inside the two-column controls grid, so it landed in the left (Shadow Call) column and floated in dead space instead of reading as a qualifier on the selected v2 mode. It also used a bare text glyph for help while the row above uses IconInfo. Move it into the right grid column, right-aligned under the v1/base/v2 chips, and match that row's label -> control -> info order with a 24px info gutter so both rows share one right edge. Below 1160px it falls back to the single-column start-aligned stack with the mode row. --- gui/src/pages/Models.tsx | 24 +++++++------ gui/src/styles-models-workspace.css | 55 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 27cd02a615..9165cde69c 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1326,19 +1326,21 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )} {v2 && v2.multiAgentMode === "v2" && ( -
- - {t("models.keepNativeOnV1")}{" "} +
+
+ {t("models.keepNativeOnV1")} + void setKeepNativeChatGptOnV1(!v2.keepNativeChatGptOnV1)} + disabled={v2Busy} + label={t("models.keepNativeOnV1")} + /> - + + - - void setKeepNativeChatGptOnV1(!v2.keepNativeChatGptOnV1)} - disabled={v2Busy} - label={t("models.keepNativeOnV1")} - /> +
)}
diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index e2cc78310b..75433cc64f 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -210,6 +210,50 @@ align-items: center; } +/* + "Keep ChatGPT on v1" is a sub-setting of the Sub-agent surface control, not a + peer of it. It sits in the right-hand grid column directly under the v1/base/v2 + chips and stays right-aligned with them, so it reads as a qualifier on the + selected mode instead of floating in the empty space beside Shadow Call. +*/ +.models-v2-keep-native-row { + grid-column: 2; + justify-self: end; + display: flex; + justify-content: flex-end; + min-width: 0; + /* Pull up against the mode row's own min-height so the pair reads as one block. */ + margin-top: calc(var(--space-1) * -1); +} + +.models-v2-keep-native { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.models-v2-keep-native-label { + color: var(--muted); + white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Sits in the same 24px gutter as the Sub-agent help button directly above it, + so both rows end on one right edge. */ +.models-v2-keep-native-info { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: 0 0 24px; + color: var(--muted); + cursor: help; +} + /* ── Provider group header (Classic + Workspace) ─────── */ .models-provider-head { @@ -436,6 +480,17 @@ flex-wrap: wrap; justify-self: start; } + + .models-v2-keep-native-row { + grid-column: 1; + justify-self: start; + justify-content: flex-start; + margin-top: 0; + } + + .models-v2-keep-native { + white-space: normal; + } } /* From da09994cf1424915038c2f2b87244ddb0a105e43 Mon Sep 17 00:00:00 2001 From: Jonathan Li <47408717+jonathanli12@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:16:42 -0700 Subject: [PATCH 04/11] test(gui): pin the keep-native-v1 switch placement Guards the layout contract the previous commit established: the switch keeps its own right-column row under the Sub-agent chips, still collapses into the single-column stack under 1160px, and the Tooltip button wraps only the info icon so the Switch is never nested inside another button. All four assertions fail against the pre-fix markup. --- .../models-keep-native-v1-placement.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 gui/tests/models-keep-native-v1-placement.test.ts diff --git a/gui/tests/models-keep-native-v1-placement.test.ts b/gui/tests/models-keep-native-v1-placement.test.ts new file mode 100644 index 0000000000..1b649e9469 --- /dev/null +++ b/gui/tests/models-keep-native-v1-placement.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; + +const modelsSource = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); +const workspaceCss = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + +/** + * "Keep ChatGPT on v1" qualifies the selected Sub-agent surface, so it has to + * render under the v1/base/v2 chips in the right-hand column of the controls + * grid. Reusing `.models-v2-mode-row` put it in the left (Shadow Call) column + * instead, where it read as an unrelated floating switch. + */ +test("keep-native-v1 switch renders in its own right-column row, not a second mode row", () => { + const block = modelsSource.slice(modelsSource.indexOf('v2.multiAgentMode === "v2"')); + const row = block.slice(0, block.indexOf("\n ") + 1); + + expect(row).toContain("models-v2-keep-native-row"); + expect(row).toContain("models.keepNativeOnV1"); + // The old markup reused the mode row and hard-coded an inline offset. + expect(row).not.toContain('className="models-v2-mode-row row" style={{ marginTop: 8 }}'); +}); + +test("keep-native-v1 row is pinned to the Sub-agent column and right-aligned", () => { + const rule = workspaceCss.slice(workspaceCss.indexOf(".models-v2-keep-native-row {")); + const body = rule.slice(0, rule.indexOf("}")); + + expect(body).toContain("grid-column: 2"); + expect(body).toContain("justify-self: end"); +}); + +test("keep-native-v1 row falls back to the single-column stack under 1160px", () => { + const narrow = workspaceCss.slice(workspaceCss.indexOf("@media (max-width: 1160px)")); + const scoped = narrow.slice(0, narrow.indexOf("@container")); + + expect(scoped).toContain(".models-v2-keep-native-row"); + expect(scoped).toContain("grid-column: 1"); + expect(scoped).toContain("justify-self: start"); +}); + +/** + * `Tooltip` renders a