From 38b8de1af9623cd0507b7cc6bcf07659be0137ad Mon Sep 17 00:00:00 2001 From: wonsh42 Date: Sun, 26 Jul 2026 20:31:19 +0900 Subject: [PATCH] fix(codex): address PR #498 review and rebase onto dev --- .../content/docs/guides/codex-app-models.md | 12 +- .../content/docs/guides/sub-agent-surface.md | 15 +- .../src/content/docs/guides/web-dashboard.md | 28 +- .../docs/ja/guides/codex-app-models.md | 12 +- .../docs/ja/guides/sub-agent-surface.md | 15 +- .../content/docs/ja/guides/web-dashboard.md | 22 +- .../docs/ja/reference/configuration.md | 7 +- .../docs/ko/guides/codex-app-models.md | 12 +- .../docs/ko/guides/sub-agent-surface.md | 15 +- .../content/docs/ko/guides/web-dashboard.md | 20 +- .../docs/ko/reference/configuration.md | 7 +- .../content/docs/reference/configuration.md | 7 +- .../docs/ru/guides/codex-app-models.md | 15 +- .../docs/ru/guides/sub-agent-surface.md | 15 +- .../content/docs/ru/guides/web-dashboard.md | 25 +- .../docs/ru/reference/configuration.md | 7 +- .../docs/zh-cn/guides/codex-app-models.md | 9 +- .../docs/zh-cn/guides/sub-agent-surface.md | 15 +- .../docs/zh-cn/guides/web-dashboard.md | 17 +- .../docs/zh-cn/reference/configuration.md | 7 +- gui/src/App.tsx | 22 +- gui/src/i18n/de.ts | 8 +- gui/src/i18n/en.ts | 8 +- gui/src/i18n/ja.ts | 8 +- gui/src/i18n/ko.ts | 8 +- gui/src/i18n/ru.ts | 8 +- gui/src/i18n/zh.ts | 8 +- gui/src/pages/dashboard-core-poll.ts | 26 +- gui/src/pages/dashboard-overview-sections.tsx | 78 +-- gui/src/pages/dashboard-shared.ts | 3 +- gui/src/pages/use-dashboard-data.ts | 41 +- gui/src/stop-proxy.ts | 66 +++ gui/tests/app-stop.test.ts | 69 +++ gui/tests/dashboard-contracts.test.ts | 37 +- gui/tests/multi-agent-guidance.test.tsx | 154 +++-- src/cli/index.ts | 62 +- src/codex/inject.ts | 128 +++- src/codex/subagent-defaults.ts | 550 ++++++++++++++++++ src/codex/sync.ts | 3 + src/config.ts | 102 +++- .../management/agent-settings-routes.ts | 32 +- src/server/management/config-routes.ts | 1 + src/types.ts | 5 + structure/02_config-and-codex-home.md | 7 + structure/03_catalog-and-subagents.md | 9 + structure/05_gui-and-management-api.md | 2 +- tests/cli-restore-back.test.ts | 52 +- tests/codex-inject-integration.test.ts | 138 ++++- tests/codex-inject.test.ts | 24 + tests/codex-journal.test.ts | 150 ++++- tests/codex-sync-api.test.ts | 61 ++ tests/config.test.ts | 138 +++++ tests/grok-lifecycle.test.ts | 15 + tests/injection-model-api.test.ts | 152 ++++- tests/subagent-defaults.test.ts | 381 ++++++++++++ 55 files changed, 2545 insertions(+), 293 deletions(-) create mode 100644 gui/src/stop-proxy.ts create mode 100644 gui/tests/app-stop.test.ts create mode 100644 src/codex/subagent-defaults.ts create mode 100644 tests/subagent-defaults.test.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 9a781a4f14..b039b338d0 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -136,9 +136,10 @@ Set the mode from the Dashboard or Models page, `ocx v2 mode v1|default|v2`, or with `{ "multiAgentMode": "v1" }`. Changes apply to new Codex sessions. :::caution -On the v2 (`multi_agent_v2`) surface, spawned sub-agents inherit the parent session's model. The -dashboard's delegation model/effort picker is v1 prompt guidance, not a proxy-side per-spawn -cross-model router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical +On the v2 (`multi_agent_v2`) surface, a spawn without an explicit model can inherit the parent +session's model. OpenCodex guidance can ask Codex to pass the selected model/effort explicitly, and +the separate native-default opt-in can supply defaults after sync/restart. Neither mechanism is a +proxy-side per-spawn router. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical behavior. ::: @@ -175,8 +176,9 @@ Codex sorts picker-visible catalog entries by ascending `priority` and advertise through `subagentModels` or the dashboard Subagents page; opencodex gives those entries priorities 0-4 in the chosen order. Other models remain callable by exact id. -The featured-model list is separate from the Dashboard's **Sub-agent delegation** guidance. In -particular, featured model overrides do not bypass v2's parent-model inheritance rule. +The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It +controls which overrides Codex offers first; it does not select a model or trigger delegation by +itself. ## Refreshing model state 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 764e8feeeb..ae9738ad90 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ The override is the final pass in both the live `/v1/models` catalog response an ### Delegation model and effort -The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. These are delegation guidance settings, not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. +The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, optionally, an `injectionEffort`. OpenCodex-authored delegation guidance can use these selections, but it remains controlled separately by **OpenCodex multi-agent guidance**. These settings are not a proxy-side spawn router. An optional `injectionPrompt` replaces the built-in guidance text entirely. + +The default-off **Use as native Codex subagent defaults** switch sets `syncCodexSubagentDefaults`. When OpenCodex manages the active Codex routing, a sync or restart applies the selected model and effort as native Codex `[agents]` defaults for newly created Codex tasks. External user-managed provider configs remain untouched. This does not trigger delegation. Existing user-owned `[agents]` defaults are preserved rather than overwritten, so they remain authoritative. `multiAgentGuidanceText` identifies the surface from the request's tools — including the Codex Desktop WebSocket path (`responses_lite`), where tools arrive inside an `additional_tools` input item instead of the request's `tools` array. @@ -56,7 +58,7 @@ To replace the built-in v2 guidance, set `injectionPrompt` (config key, or `PUT - **Dashboard** → first stat cell: click **v1**, **base**, or **v2**. - **Models** page → top-row segmented control. - Both pages have a **?** button that opens a help modal with a link back here. -- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. Enable **OpenCodex multi-agent guidance** for delegation instructions, or independently enable **Use as native Codex subagent defaults** to apply the selection to new Codex tasks after sync/restart. The defaults switch does not cause delegation, and existing user-owned `[agents]` defaults are preserved rather than overwritten. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Opt in to native Codex defaults for new tasks after sync/restart (requires a model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Set a custom guidance prompt ({{model}}/{{effort}}/{{roster}} placeholders) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` returns `model`, `effort`, `prompt`, the global `efforts` ladder, and enabled native/routed `available` models. For PUT, omitting `effort` or `prompt` keeps the current value, `null` clears it, and clearing `model` always clears the effort too. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. +`GET /api/injection-model` returns `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, the global `efforts` ladder, and enabled native/routed `available` models. PUT is partial: omitted fields keep their current values, `null` clears nullable values, and clearing `model` always clears both the effort and native-default opt-in. Enabling `syncCodexSubagentDefaults` requires a model. The API validates effort against the global Codex ladder; Codex still validates a spawn effort against the target catalog entry. ## Reasoning effort -The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; it does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. +The optional sub-agent effort setting is stored as `injectionEffort` and is meaningful only with an injection model. It adds a `reasoning_effort` instruction to the injected v2 guidance; with native-default sync enabled, it also becomes the `[agents]` reasoning default for new Codex tasks after sync/restart. It does not change the parent session's effort. On any fork that accepts overrides, Codex applies a `reasoning_effort` passed to `spawn_agent` directly. `ultra` ranks above `max` in the Codex catalog and adds automatic-delegation semantics, but it never reaches a provider as a literal wire value. Codex converts `ultra` to `max` at the client boundary. opencodex then keeps the provider request valid: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 739e6acefc..94d00b4ee8 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | Area | What it does | | --- | --- | | **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. | -| **Sub-agent delegation** | Choose a native or routed guidance model and an optional reasoning effort for v1 delegation prompts. This is not a per-spawn router; see below. | +| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. | | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | @@ -61,17 +61,29 @@ The **Models** switches show final Codex visibility: a routed model is on only w ## Delegation picker vs spawn routing The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, -`injectionEffort`. On a v1 turn, opencodex injects guidance telling the parent agent which exact -model and reasoning effort to pass to `spawn_agent`. Choosing a model enables that guidance at any -parent reasoning effort; clearing the model also clears the stored effort. +`injectionEffort`. **OpenCodex multi-agent guidance** independently controls the delegation +instructions that use those values. On eligible v2 turns, that guidance tells the parent +agent which exact model and reasoning effort to pass to `spawn_agent`; clearing the model also clears +the stored effort. + +The default-off **Use as native Codex subagent defaults** switch applies the same selection to Codex's +native `[agents]` defaults on the next sync/restart when OpenCodex manages the active Codex routing. +External user-managed provider configs remain untouched. Those defaults affect newly created Codex tasks +and do not themselves cause delegation. Existing user-owned `[agents]` defaults are preserved rather +than overwritten, so they may continue to override the requested defaults. :::caution -This picker is delegation guidance for the v1 compatibility surface. On `multi_agent_v2`, the -current proxy does not append the v1 injection message, and every spawned sub-agent inherits the -parent session's model. It is not a proxy-side cross-model router. See +Neither control is a proxy-side cross-model spawn router. OpenCodex guidance asks Codex to pass +overrides to `spawn_agent`; native `[agents]` defaults apply only when Codex creates a new task after +they have been synchronized. See [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical v1/base/v2 behavior. ::: +The spawn override guarantee applies to the **built-in** v2 guidance text. A custom +`injectionPrompt` replaces that text entirely and must include `{{model}}` and `{{effort}}` +placeholders (and optionally `{{roster}}`) or those values will not appear in the injected +guidance. + The picker offers enabled native and routed models plus the global Codex effort ladder. The API validates the selected effort globally; Codex still validates a spawn effort against the target catalog entry. @@ -107,7 +119,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `POST /api/sync` | Rebuild the shared model catalog and stale the Codex model cache. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Check, run, and monitor self-update jobs. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | -| `GET` / `PUT /api/injection-model` | Read or set the v1 delegation guidance model and optional effort. | +| `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | | `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, or remove providers. | | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index b041abdcfe..035e50075f 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -112,10 +112,10 @@ opencodex は全カタログ項目の `multi_agent_version` を制御する 3 セッションから適用されます。 :::caution -v2(`multi_agent_v2`)サーフェスで生成されたサブエージェントは親セッションのモデルを継承します。ダッシュボードの -委任モデル/強度セレクターは v1 プロンプトガイダンスであり、プロキシがスポーンごとに別モデルにルーティングする機能では -ありません。正確な動作は[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を -参照してください。 +v2(`multi_agent_v2`)サーフェスでは、モデルを明示しないスポーンは親セッションのモデルを継承できます。 +OpenCodex ガイダンスは選択したモデル/強度を明示的に渡すよう Codex に指示でき、別のネイティブデフォルト設定は +sync/restart 後に既定値を提供できます。どちらもプロキシ側のスポーン単位ルーターではありません。正確な動作は +[サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: ## 最上位推論段階 @@ -151,8 +151,8 @@ Codex はピッカーに表示されるカタログ項目を `priority` 昇順 ネイティブ ID または `provider/model` ID を最大 5 つ選ぶと opencodex が選択順に priority 0-4 を 付与します。残りのモデルも正確な ID で直接呼び出し可能です。 -フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** ガイダンスとは別物です。特にフィーチャー済みモデル -オーバーライドで v2 の親モデル継承ルールをバイパスできません。 +フィーチャー済みモデル一覧はダッシュボードの **Sub-agent delegation** 選択とは別物です。Codex が最初に提示する +オーバーライドを決めるだけで、モデルの選択や委任の開始は行いません。 ## モデル状態のリフレッシュ diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index cc57ae65e7..59dce3ccbf 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル ### 委任モデルと推論強度 -ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。この値は委任ガイドを作る設定であり、プロキシがスポーンリクエストを別モデルに再ルーティングする設定ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 +ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは `multiAgentGuidanceEnabled` で別に制御されます。これらはプロキシがスポーンリクエストを別モデルに再ルーティングする規則ではありません。`injectionPrompt` を指定すると内蔵ガイド文言全体を希望テキストに差し替えできます。 + +`syncCodexSubagentDefaults` を明示的に有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort が Codex ネイティブの `[agents]` サブエージェント既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される Codex タスクだけに適用され、設定自体が委任を発生させることはありません。既存のユーザー所有 `[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 `multiAgentGuidanceText` はリクエストに入ってきたツール一覧でサーフェスを判定します。Codex Desktop の WebSocket 経路(`responses_lite`)のようにツールがリクエストの `tools` 配列ではなく `additional_tools` input 項目として届く場合も認識します。 @@ -56,7 +58,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル - **ダッシュボード** → 最初のスタットセルで **v1**、**base**、**v2** を選択します。 - **モデル** ページ → 上部セグメントコントロールで選択します。 - 両ページとも **?** ボタンを押すとこのドキュメントに繋がるヘルプモーダルが開きます。 -- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。**ネイティブ Codex サブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart から新しい Codex タスクにも同じ選択値が適用されます。外部のユーザー管理 provider 設定は変更しません。このトグルは委任ガイダンスのトグルとは独立しています。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 選択値を Codex ネイティブのサブエージェント既定値へ同期するよう設定(モデルが必要) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # カスタムガイドプロンプトを設定({{model}}/{{effort}}/{{roster}} プレースホルダ) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` は `model`、`effort`、`prompt`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT で `effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`model` を消去すると推論強度も常に一緒に消去されます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 +`GET /api/injection-model` は `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、グローバル `efforts` 段階、有効化されたネイティブ・ルーティングモデルである `available` を返します。PUT は部分更新です。`effort` や `prompt` を省略すると既存値を維持し、`null` なら消去します。`syncCodexSubagentDefaults: true` には選択済みモデルが必要で、`model` を消去すると推論強度の消去とネイティブ既定値の同期解除も行われます。API はグローバル Codex 段階に合う推論強度か検証し、Codex はスポーン時に対象カタログ項目がその強度をサポートするか再検証します。 ## 推論強度 -サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 +サブエージェント推論強度は `injectionEffort` に保存され注入モデルがあるときのみ意味を持ちます。この値は注入 v2 ガイドに `reasoning_effort` 指示を追加し、親セッションの推論強度は変えません。`syncCodexSubagentDefaults` が有効で OpenCodex が有効な Codex ルーティングを管理している場合は、次回の sync または restart から新しい Codex タスクのネイティブなサブエージェント既定 effort としても使われます。オーバーライドが許可される fork では `spawn_agent` に渡された `reasoning_effort` を Codex がそのまま適用します。 `ultra` は Codex カタログで `max` より高い段階で自動委任の意味が加わりますが、プロバイダー wire に `ultra` という値がそのまま渡るわけではありません。Codex がクライアント境界で `ultra` を `max` に変え、opencodex がプロバイダーに合う有効な値に調整します。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 2a253dc9c7..b2112d0fbf 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | 領域 | 機能 | --- | --- | | **ダッシュボード要約** | マルチエージェントモード、オンライン状態、バージョン、稼働時間、プロバイダー数、直近30日トークン合計、アクティブプロバイダーと利用可能なネイティブ/ルーティングモデルを表示します。 | -| **サブエージェント委任** | v1 委任プロンプトに入れるネイティブまたはルーティングモデルとオプションの推論強度を選びます。スポーンごとのルーターではありません。下記の説明を確認してください。 | +| **サブエージェント委任** | OpenCodex の委任ガイダンスとオプションの Codex ネイティブサブエージェント既定値で共有するネイティブ/ルーティングモデルと任意の推論強度を選びます。スポーンごとのルーターではありません。下記を参照してください。 | | **サイドカー** | ウェブ検索モデルと強度、画像説明モデルを選択します。次回リクエストから適用されます。 | | **メンテナンス** | Codex モデルカタログを再同期し、プロジェクトローカル設定のバイパス警告を確認し、latest/preview 更新を照会またはオプションのプロキシ再起動と共にインストールします。 | | **起動安全性** | 注入された Codex ルーティングが再起動後も機能するか、サービスと launcher shim の状態、正確な修復コマンドと共に表示します。 | @@ -56,14 +56,20 @@ bun run dev:gui ## 委任セレクターとスポーンルーティングの違い ダッシュボードの **サブエージェント委任** セレクターは `injectionModel` とオプションの `injectionEffort` を -保存します。v1 ターンでは opencodex が親エージェントに `spawn_agent` に渡す正確なモデルと推論 -強度を伝えるガイドを注入します。モデルを選ぶと親の現在の推論強度に関係なくこのガイドが -有効化され、モデルを消去すると保存された強度も一緒に消去されます。 +保存します。選択値は OpenCodex が作成する委任ガイダンスで使われ、そのガイダンスは +`multiAgentGuidanceEnabled` で別に制御されます。モデルを消去すると保存済み effort も消去され、 +ネイティブ既定値の同期も無効になります。 + +**Codex ネイティブサブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex +ルーティングを管理している場合、次回の sync または restart で選択したモデルと effort がネイティブ +`[agents]` 既定値として適用されます。外部のユーザー管理 provider 設定は変更しません。この既定値は新しく作成される +Codex タスクだけに適用され、このオプション自体が委任を発生させることはありません。既存のユーザー所有 +`[agents]` 既定値は上書きせず保持するため、要求した既定値と実際の Codex 既定値が異なる場合があります。 :::caution -このセレクターは v1 互換サーフェス用の委任ガイドです。`multi_agent_v2` では現在プロキシは v1 注入 -メッセージを追加せず、生成されたすべてのサブエージェントが親セッションのモデルを継承します。プロキシが -スポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は +2 つのトグルは独立しています。OpenCodex の委任ガイダンスを無効にしてもネイティブ既定値の同期は +無効にならず、ネイティブ既定値の同期を有効にしても委任ガイダンスや委任そのものは有効になりません。 +どちらもプロキシがスポーンごとにモデルを変えるルーターではありません。v1/base/v2 の正確な動作は [サブエージェントサーフェス](/ja/guides/sub-agent-surface/)を参照してください。 ::: @@ -97,7 +103,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 自己更新作業を確認、実行、追跡します。 | | `GET` / `PUT /api/sidecar-settings` | 検索/ビジョンサイドカーモデル設定を読むか変えます。 | -| `GET` / `PUT /api/injection-model` | v1 委任ガイドモデルとオプションの強度を読むか変えます。 | +| `GET` / `PUT /api/injection-model` | 委任ガイダンスのモデル/effort、ガイダンストグル、Codex ネイティブサブエージェント既定値の同期トグルを読み取りまたは変更します。 | | `GET` / `PUT /api/v2` | サーフェスモード、Codex 機能フラグ、v2 スレッド上限を読むか変えます。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | プロバイダー一覧の参照、追加/差替、有効化/無効化、削除。 | | `GET /api/models` · `PUT /api/disabled-models` | ネイティブ/ルーティングモデル行を参照し共有 disabled model 一覧を更新します。 | diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index b42afd31cc..e9a42e87dd 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -31,12 +31,13 @@ namespaced selected id を bare id に変えます。 | `openaiProviderTierVersion?` | `2` | 移行設定 | 単一の省略可能 OpenAI projection 完了マーカー。 | | `defaultProvider` | `string` | `"openai"` | ルーティングでより良い match が見つからなかったときに使うプロバイダー。 | | `subagentModels?` | `string[]` | `gpt-5.5`、GPT-5.6 3種、`gpt-5.4-mini` | Codex サブエージェントセレクターの先頭に表示するネイティブ slug または `provider/model` id。最大 5 つで、明示的な空配列もそのまま保存します。v2 ガイダンスのロスターは、Codex の picker-visible・v2 互換・priority 順の先頭 5 件との設定済みモデルの共通部分で、正規カタログ slug と利用可能な effort ラダーを使います。除外された項目も設定には残ります。 | -| `injectionModel?` | `string` | — | 注入される multi-agent 案内(v2 surface)に入るネイティブ/ルーティングモデル。委任案内でこのモデルを `fork_turns: "none"` とともに `spawn_agent` に渡します。 | -| `injectionEffort?` | `string` | — | 希望する `spawn_agent` reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ちます。 | +| `injectionModel?` | `string` | — | 希望するネイティブ/ルーティングのサブエージェントモデル。別の `multiAgentGuidanceEnabled` が制御する OpenCodex 作成の v2 委任ガイダンスで使われ、`syncCodexSubagentDefaults` のオプトインにより新しいタスクの Codex ネイティブ既定値にも適用できます。 | +| `injectionEffort?` | `string` | — | 希望するサブエージェント reasoning effort(`low` から `ultra`)。`injectionModel` と一緒に使うときだけ意味を持ち、委任ガイダンスとオプションの Codex ネイティブ既定値で使われます。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex が有効な Codex ルーティングを管理している場合、選択した `injectionModel` / `injectionEffort` を次回の sync または restart で Codex ネイティブの `[agents]` サブエージェント既定値へ適用するオプトイン設定。外部のユーザー管理 provider 設定は変更しません。新しく作成される Codex タスクだけに作用し、設定自体が委任を発生させることはありません。既存のユーザー所有対象項目は競合として上書きせず保持します。`injectionModel` が必要で、モデルを消去するとこのオプトインも解除されます。`GET/PUT /api/injection-model` の部分更新フィールドとして公開されます。 | | `effortCap?` | `string` | — | reasoning effort にリクエストごとに適用する強制上限。マルチエージェント V2 専用機能で、自身のツールリストに V2 協調 surface を持つメインターンと、`x-openai-subagent: collab_spawn` ヘッダーまたは `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"` 標識が正確に一致する spawn された子ターンに適用されます(標識のついた子は自身のツール surface と無関係に適用対象です)。通常のメインターンと V1 surface メインターンは触れず、コンパクションターンは常に上限をバイパスし、`multiAgentMode: "v1"` は上限機能全体を無効化します(ダッシュボードもパネルを隠します)。`low` から `ultra` を許可し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `subagentEffortCap?` | `string` | — | 同じ強制上限を codex-rs 標識が正確に一致する spawn された子ターンにだけ適用します: `x-openai-subagent: collab_spawn` または `x-codex-turn-metadata` の `"subagent_kind": "thread_spawn"`。それ以外の内部サブエージェントカテゴリ(レビュー、コンパクション、メモリ整理)はこの上限にかからず、`multiAgentMode: "v1"` は機能全体を無効化します。`low` から `ultra` を許可し両方の上限が設定されていればより低い値を適用し、値を上げずに下げるだけです。上限以下でモデルがサポートする最も高い段階に下げます。モデルが effort 制御を公開しない、または上限以下にサポート段階がない場合は effort フィールドを削除しプロバイダーのデフォルトを適用します。`max` と `ultra` も許可しますが、より低いランク上限を作りません(クライアントが `ultra` を `max` に変換するためリクエストは `low` から `max` で入ります)。ただし、既知のモデル effort ラダーに従い段階が下がるかフィールドが削除される可能性があります。ダッシュボードセレクターは `low` から `xhigh` まで提供します。`GET /api/effort-caps` と `PUT /api/effort-caps` で管理します。 | | `injectionPrompt?` | `string` | — | 注入される v2 案内本文を丸ごと差し替えるカスタムテキスト。`{{model}}`、`{{effort}}`、`{{roster}}` placeholder が置換され、発火条件はそのままです。`PUT /api/injection-model` の `prompt` キーでも設定できます。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex が作成する multi-agent developer ガイダンスだけを制御します。未設定/`true` は v1/v2 ガイダンスを維持し、`false` は Codex ネイティブの `[agents]` 既定値、collaboration surface、`subagentModels`、routing、effort cap を変えずに両方を抑止します。`GET/PUT /api/injection-model` は有効値を返し、PUT は部分更新です。 | | `disabledModels?` | `string[]` | — | Codex で隠すモデル。ルーティングされた `provider/model` id はカタログと `/v1/models` から除外します。`gpt-5.4` のような通常のネイティブ GPT slug はカタログ項目を `visibility: "hide"` に変え、通常の `/v1/models` 一覧から外します。ダッシュボードの Models ページでモデルごとに切り替えできます。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3 段階 multi-agent surface override。`"v1"` は上流 pin より優先してすべてのモデルを v1 に、`"default"` は上流 model pin(sol/terra=v2、luna=v1)に従い、`"v2"` はすべてを v2 に強制します。ダッシュボードの Models ページまたは `ocx v2 mode` で設定します。 | | `providerContextCaps?` | `Record` | `{}` | プロバイダー別の Codex 表示 context cap。既知の context window を下げるだけです。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 3dd14431cc..f255a1a2c5 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -114,10 +114,10 @@ Dashboard나 Models 페이지, `ocx v2 mode v1|default|v2`, 또는 세션부터 적용됩니다. :::caution -v2(`multi_agent_v2`) 서피스에서 생성된 서브에이전트는 부모 세션의 모델을 상속합니다. 대시보드의 -위임 모델/강도 선택기는 v1 프롬프트 안내이며, 프록시가 스폰마다 다른 모델로 라우팅하는 기능이 -아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 -참고하세요. +v2(`multi_agent_v2`) 서피스에서는 모델을 명시하지 않은 스폰이 부모 세션의 모델을 상속할 수 있습니다. +OpenCodex 안내는 선택한 모델/강도를 명시적으로 전달하도록 Codex에 요청할 수 있고, 별도의 네이티브 기본값 +옵트인은 sync/restart 후 기본값을 제공할 수 있습니다. 어느 쪽도 프록시가 스폰마다 모델을 정하는 라우터는 +아닙니다. 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: ## 최상위 reasoning 단계 @@ -153,8 +153,8 @@ Codex는 선택기에 표시되는 카탈로그 항목을 `priority` 오름차 네이티브 id 또는 `provider/model` id를 최대 5개 고르면 opencodex가 선택 순서대로 priority 0-4를 부여합니다. 나머지 모델도 정확한 id로 직접 호출할 수 있습니다. -featured 모델 목록은 Dashboard의 **Sub-agent delegation** 안내와 별개입니다. 특히 featured 모델 -override로 v2의 부모 모델 상속 규칙을 우회할 수 없습니다. +featured 모델 목록은 Dashboard의 **Sub-agent delegation** 선택과 별개입니다. Codex가 먼저 보여 줄 +override를 정할 뿐, 모델을 선택하거나 위임을 시작하지는 않습니다. ## 모델 상태 새로고침 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 733b9bd59d..5623a6ae60 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 ### 위임 모델과 추론 강도 -대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 이 값은 위임 가이드를 만드는 설정이지, 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 설정이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. +대시보드의 **서브에이전트 위임** 선택기는 `injectionModel`과 선택 사항인 `injectionEffort`를 저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되며, 이 가이드는 `multiAgentGuidanceEnabled`가 별도로 제어합니다. 이 값은 프록시가 스폰 요청을 다른 모델로 다시 라우팅하는 규칙이 아닙니다. `injectionPrompt`를 지정하면 내장 가이드 문구 전체를 원하는 텍스트로 교체할 수 있습니다. + +`syncCodexSubagentDefaults`를 명시적으로 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 경우 다음 sync 또는 restart에서 선택한 모델과 강도를 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용합니다. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되며 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 보존하므로, 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. `multiAgentGuidanceText`는 요청에 들어온 툴 목록으로 서피스를 판별합니다. Codex Desktop의 WebSocket 경로(`responses_lite`)처럼 툴이 요청의 `tools` 배열 대신 `additional_tools` input 항목으로 도착하는 경우도 인식합니다. @@ -56,7 +58,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 - **대시보드** → 첫 번째 스탯 셀에서 **v1**, **base**, **v2**를 선택합니다. - **모델** 페이지 → 상단 세그먼트 컨트롤에서 선택합니다. - 두 페이지 모두 **?** 버튼을 누르면 이 문서로 연결되는 도움말 모달이 열립니다. -- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. **Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리할 때 다음 sync 또는 restart부터 새 Codex task에도 같은 선택값을 적용합니다. 외부 사용자 관리 provider 설정은 그대로 유지합니다. 이 토글은 위임 가이드 토글과 별개입니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 선택값을 Codex 네이티브 서브에이전트 기본값으로 동기화하도록 설정(모델 필요) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 커스텀 가이드 프롬프트 설정 ({{model}}/{{effort}}/{{roster}} 플레이스홀더) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model`은 `model`, `effort`, `prompt`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT에서 `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `model`을 지우면 추론 강도도 항상 함께 지워집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. +`GET /api/injection-model`은 `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, 전역 `efforts` 단계, 활성화된 네이티브·라우팅 모델인 `available`을 반환합니다. PUT은 부분 업데이트입니다. `effort`나 `prompt`를 생략하면 기존 값을 유지하고, `null`이면 지웁니다. `syncCodexSubagentDefaults: true`에는 선택된 모델이 필요하며, `model`을 지우면 추론 강도와 네이티브 기본값 동기화도 함께 꺼집니다. API는 전역 Codex 단계에 맞는 추론 강도인지 검증하고, Codex는 스폰 시 대상 카탈로그 항목이 그 강도를 지원하는지 다시 검증합니다. ## 추론 강도 -서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. +서브에이전트 추론 강도는 `injectionEffort`에 저장되며 주입 모델이 있을 때만 의미가 있습니다. 이 값은 주입된 v2 가이드에 `reasoning_effort` 지시를 추가하며, 부모 세션의 추론 강도를 바꾸지는 않습니다. `syncCodexSubagentDefaults`를 켜고 OpenCodex가 활성 Codex 라우팅을 관리하는 경우에는 다음 sync 또는 restart부터 새 Codex task의 네이티브 서브에이전트 기본 강도로도 사용됩니다. 오버라이드가 허용되는 fork에서는 `spawn_agent`에 전달된 `reasoning_effort`를 Codex가 그대로 적용합니다. `ultra`는 Codex 카탈로그에서 `max`보다 높은 단계이며 자동 위임 의미가 더해지지만, 프로바이더 와이어에는 `ultra`라는 값이 그대로 전달되지 않습니다. Codex가 클라이언트 경계에서 `ultra`를 `max`로 바꾸고, opencodex가 프로바이더에 맞는 유효한 값으로 조정합니다. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 8ab11d4e6d..506833ccc7 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | 영역 | 기능 | | --- | --- | | **Dashboard 요약** | Multi-agent 모드, 온라인 상태, 버전, 가동 시간, 프로바이더 수, 최근 30일 토큰 합계, 활성 프로바이더와 사용 가능한 네이티브/라우팅 모델을 보여줍니다. | -| **Sub-agent delegation** | v1 위임 프롬프트에 넣을 네이티브 또는 라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | +| **Sub-agent delegation** | OpenCodex 위임 가이드와 선택적인 Codex 네이티브 서브에이전트 기본값이 함께 사용할 네이티브/라우팅 모델과 선택적 reasoning 강도를 고릅니다. 스폰별 라우터는 아닙니다. 아래 설명을 확인하세요. | | **사이드카** | 웹 검색 모델과 강도, 이미지 설명 모델을 선택합니다. 다음 요청부터 적용됩니다. | | **Maintenance** | Codex 모델 카탈로그를 다시 동기화하고, 프로젝트 로컬 설정의 우회 경고를 확인하고, latest/preview 업데이트를 조회하거나 선택적 프록시 재시작과 함께 설치합니다. | | **시작 안전성** | 주입된 Codex 라우팅이 재부팅 후에도 유지되는지 서비스와 launcher shim 상태, 정확한 복구 명령과 함께 표시합니다. | @@ -56,13 +56,19 @@ bun run dev:gui ## 위임 선택기와 스폰 라우팅의 차이 Dashboard의 **Sub-agent delegation** 선택기는 `injectionModel`과 선택적인 `injectionEffort`를 -저장합니다. v1 턴에서는 opencodex가 부모 에이전트에게 `spawn_agent`에 넘길 정확한 모델과 reasoning -강도를 알려 주는 안내를 주입합니다. 모델을 고르면 부모의 현재 reasoning 강도와 관계없이 이 안내가 -활성화되며, 모델을 지우면 저장된 강도도 함께 지워집니다. +저장합니다. 선택한 값은 OpenCodex가 작성하는 위임 가이드에 사용되고, 이 가이드는 +`multiAgentGuidanceEnabled`가 별도로 제어합니다. 모델을 지우면 저장된 강도도 지워지고 네이티브 +기본값 동기화도 꺼집니다. + +**Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리하는 +경우 다음 sync 또는 restart에서 선택한 모델과 강도를 네이티브 `[agents]` 기본값으로 적용합니다. 외부 +사용자 관리 provider 설정은 변경하지 않습니다. 이 기본값은 새로 생성되는 Codex task에만 적용되고, +이 옵션 자체가 위임을 일으키지는 않습니다. 기존 사용자 소유 `[agents]` 기본값은 덮어쓰지 않고 +보존하므로 요청한 기본값과 실제 Codex 기본값이 다를 수 있습니다. :::caution -이 선택기는 v1 호환 서피스용 위임 안내입니다. `multi_agent_v2`에서는 현재 프록시가 v1 주입 -메시지를 덧붙이지 않으며, 생성된 모든 서브에이전트가 부모 세션의 모델을 상속합니다. 프록시가 +두 토글은 서로 독립적입니다. OpenCodex 위임 가이드를 꺼도 네이티브 기본값 동기화는 꺼지지 않고, +네이티브 기본값 동기화를 켜도 위임 가이드를 켜거나 위임을 발생시키지 않습니다. 어느 쪽도 프록시가 스폰마다 모델을 바꾸는 라우터가 아닙니다. v1/base/v2의 정확한 동작은 [서브에이전트 서피스](/ko/guides/sub-agent-surface/)를 참고하세요. ::: @@ -99,7 +105,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | `POST /api/sync` | 공유 모델 카탈로그를 다시 만들고 Codex 모델 캐시를 오래된 상태로 표시합니다. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 자체 업데이트 작업을 확인, 실행, 추적합니다. | | `GET` / `PUT /api/sidecar-settings` | 검색/비전 사이드카 모델 설정을 읽거나 바꿉니다. | -| `GET` / `PUT /api/injection-model` | v1 위임 안내 모델과 선택적 강도를 읽거나 바꿉니다. | +| `GET` / `PUT /api/injection-model` | 위임 가이드의 모델/강도, 가이드 토글, Codex 네이티브 서브에이전트 기본값 동기화 토글을 읽거나 바꿉니다. | | `GET` / `PUT /api/v2` | 서피스 모드, Codex 기능 플래그, v2 thread 상한을 읽거나 바꿉니다. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 프로바이더 목록 조회, 추가/교체, 활성화/비활성화, 제거. | | `GET /api/models` · `PUT /api/disabled-models` | 네이티브/라우팅 모델 행을 조회하고 공용 disabled model 목록을 갱신합니다. | diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index ba7b7c5ecf..f56e6288dd 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -32,12 +32,13 @@ namespaced selected id를 bare id로 바꿉니다. | `openaiProviderTierVersion?` | `2` | migration 설정 | 단일 옵션형 OpenAI projection 완료 마커. | | `defaultProvider` | `string` | `"openai"` | 라우팅에서 더 나은 match를 찾지 못했을 때 쓸 프로바이더. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 3종, `gpt-5.4-mini` | Codex 서브에이전트 선택기 앞쪽에 표시할 네이티브 slug 또는 `provider/model` id. 최대 5개이며, 명시적인 빈 배열도 그대로 보존합니다. v2 가이던스 로스터는 설정 목록과 Codex의 picker-visible·v2 호환·priority 순 상위 5개의 교집합이며 정규 카탈로그 slug와 사용 가능한 effort 사다리를 씁니다. 제외된 항목도 설정에는 남습니다. | -| `injectionModel?` | `string` | — | 주입되는 multi-agent 안내(v2 표면)에 들어갈 네이티브/라우팅 모델. 위임 안내에서 이 모델을 `fork_turns: "none"`과 함께 `spawn_agent`에 넘기게 합니다. | -| `injectionEffort?` | `string` | — | 선호하는 `spawn_agent` reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있습니다. | +| `injectionModel?` | `string` | — | 선호하는 네이티브/라우팅 서브에이전트 모델. 별도 `multiAgentGuidanceEnabled`가 제어하는 OpenCodex 작성 v2 위임 가이드에서 사용하며, `syncCodexSubagentDefaults`를 선택하면 새 task의 Codex 네이티브 기본값으로도 적용할 수 있습니다. | +| `injectionEffort?` | `string` | — | 선호하는 서브에이전트 reasoning effort(`low`부터 `ultra`). `injectionModel`과 함께 쓸 때만 의미가 있으며, 위임 가이드와 선택적인 Codex 네이티브 기본값에서 사용합니다. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | OpenCodex가 활성 Codex 라우팅을 관리할 때 선택한 `injectionModel`/`injectionEffort`를 다음 sync 또는 restart에서 Codex 네이티브 `[agents]` 서브에이전트 기본값으로 적용하는 선택 기능. 외부 사용자 관리 provider 설정은 변경하지 않습니다. 새로 생성되는 Codex task에만 적용하고 위임 자체를 일으키지는 않습니다. 기존 사용자 소유 대상 항목은 충돌로 취급해 덮어쓰지 않고 보존합니다. `injectionModel`이 필요하며 모델을 지우면 이 옵션도 꺼집니다. `GET/PUT /api/injection-model`의 부분 업데이트 필드로 제공됩니다. | | `effortCap?` | `string` | — | reasoning effort에 요청별로 적용하는 강제 상한입니다. 멀티 에이전트 V2 전용 기능으로, 자체 도구 목록에 V2 협업 표면이 있는 메인 턴과, `x-openai-subagent: collab_spawn` 헤더 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"` 표식이 정확히 일치하는 스폰된 자식 턴에 적용됩니다(표식이 붙은 자식은 자체 도구 표면과 무관하게 적용 대상입니다). 일반 메인 턴과 V1 표면 메인 턴은 건드리지 않고, 컴팩션 턴은 항상 상한을 우회하며, `multiAgentMode: "v1"`은 상한 기능 전체를 비활성화합니다(대시보드도 패널을 숨깁니다). `low`부터 `ultra`까지 허용하며 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `subagentEffortCap?` | `string` | — | 같은 강제 상한을 codex-rs 표식이 정확히 일치하는 스폰된 자식 턴에만 적용합니다: `x-openai-subagent: collab_spawn` 또는 `x-codex-turn-metadata`의 `"subagent_kind": "thread_spawn"`. 그 외 내부 서브에이전트 범주(리뷰, 컴팩션, 메모리 정리)는 이 상한에 걸리지 않으며, `multiAgentMode: "v1"`은 기능 전체를 비활성화합니다. `low`부터 `ultra`까지 허용하며 두 상한이 모두 설정되면 더 낮은 값이 적용되고, 값을 높이지 않고 낮추기만 합니다. 상한 이하에서 모델이 지원하는 가장 높은 단계로 내립니다. 모델이 effort 제어를 노출하지 않거나 상한 이하에 지원 단계가 없으면 effort 필드를 제거하고 프로바이더 기본값을 적용합니다. `max`와 `ultra`도 허용하지만 더 낮은 rank 상한을 만들지는 않습니다(클라이언트가 `ultra`를 `max`로 변환하므로 요청은 `low`부터 `max`로 들어옵니다). 단, 알려진 모델 effort 사다리에 따라 단계가 내려가거나 필드가 제거될 수 있습니다. 대시보드 선택기는 `low`부터 `xhigh`까지 제공합니다. `GET /api/effort-caps`와 `PUT /api/effort-caps`로 관리합니다. | | `injectionPrompt?` | `string` | — | 주입되는 v2 안내 본문을 통째로 교체하는 커스텀 텍스트. `{{model}}`, `{{effort}}`, `{{roster}}` 플레이스홀더가 치환되며 발화 조건은 그대로입니다. `PUT /api/injection-model`의 `prompt` 키로도 설정할 수 있습니다. | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | OpenCodex가 작성하는 multi-agent developer 가이던스만 제어합니다. 미설정/`true`는 v1/v2 가이던스를 유지하고, `false`는 Codex 네이티브 `[agents]` 기본값, collaboration surface, `subagentModels`, routing, effort cap을 바꾸지 않고 둘 다 억제합니다. `GET/PUT /api/injection-model`은 유효값을 제공하며 PUT은 부분 업데이트입니다. | | `disabledModels?` | `string[]` | — | Codex에서 숨길 모델. 라우팅된 `provider/model` id는 카탈로그와 `/v1/models`에서 제외합니다. `gpt-5.4` 같은 일반 네이티브 GPT slug는 카탈로그 항목을 `visibility: "hide"`로 바꾸고 일반 `/v1/models` 목록에서 뺍니다. 대시보드 Models 페이지에서 모델별로 전환할 수 있습니다. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3단계 multi-agent surface override. `"v1"`은 업스트림 pin보다 우선해 모든 모델을 v1로, `"default"`는 업스트림 model pin(sol/terra=v2, luna=v1)을 따르고, `"v2"`는 모두 v2로 강제합니다. 대시보드 Models 페이지나 `ocx v2 mode`에서 설정합니다. | | `providerContextCaps?` | `Record` | `{}` | 프로바이더별 Codex 표시 context cap. 알려진 context window를 낮추기만 합니다. | diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 66ceca796c..c25f595257 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -36,12 +36,13 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `openaiProviderTierVersion?` | `2` | set by migration | Marks the single option-aware OpenAI projection as complete. | | `defaultProvider` | `string` | `"openai"` | Provider used when routing finds no better match. | | `subagentModels?` | `string[]` | `gpt-5.5`, GPT-5.6 trio, `gpt-5.4-mini` | Up to 5 native slugs or `provider/model` ids featured first in Codex's subagent picker. The v2 guidance roster is the configured intersection of Codex's picker-visible, v2-compatible, priority-sorted first five, using canonical catalog slugs and available effort ladders; excluded entries remain configured. An explicit empty list is preserved. | -| `injectionModel?` | `string` | — | Preferred native or routed model named in the injected multi-agent guidance (v2 surface); delegation is told to pass this exact model to `spawn_agent` with `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Preferred `spawn_agent` reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`. | +| `injectionModel?` | `string` | — | Preferred native or routed sub-agent model. OpenCodex-authored v2 guidance tells delegation to pass this exact model to `spawn_agent` with `fork_turns: "none"`; the separate `syncCodexSubagentDefaults` opt-in can also apply it as Codex's native default for new tasks. | +| `injectionEffort?` | `string` | — | Preferred sub-agent reasoning effort (`low` through `ultra`). Only meaningful with `injectionModel`; used by delegation guidance and, when opted in, the native Codex subagent default. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt in to applying `injectionModel` and optional `injectionEffort` as native Codex `[agents]` defaults during sync/restart when OpenCodex manages the active Codex routing. External user-managed provider configs remain untouched. The defaults affect newly created Codex tasks and do not themselves cause delegation. Unmarked user-owned target entries are preserved as conflicts instead of being overwritten and remain authoritative. Requires `injectionModel`; clearing the model clears this opt-in. Exposed by `GET/PUT /api/injection-model` as a partial-update field. | | `effortCap?` | `string` | — | Hard per-request ceiling for reasoning effort. A multi-agent V2 feature: it applies to main turns whose own tool list carries the V2 collab surface, plus spawned-child turns marked with exactly `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata` (marked children qualify regardless of their own tool surface). Plain and V1-surface main turns are untouched, compaction turns always bypass caps, and `multiAgentMode: "v1"` disables caps entirely (the Dashboard hides the panel). Accepts `low` through `ultra`; caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | The same hard ceiling, applied only to spawned-child turns identified by codex-rs markers matched exactly: `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` in `x-codex-turn-metadata`. Other internal sub-agent categories (review, compaction, memory consolidation) never trip this cap, and `multiAgentMode: "v1"` disables it entirely. Accepts `low` through `ultra`; when both caps are set, the lower one wins, and caps only lower, never raise. Snaps down to the highest supported rung at or below the cap. If the model exposes no effort control, or no supported rung fits under the cap, the effort field is removed and the provider default applies. `max` and `ultra` are accepted but do not impose a lower rank ceiling (requests arrive as `low` through `max` after the client's `ultra` → `max` conversion), though known model ladders may still cause snap-down or strip. The Dashboard picker offers `low` through `xhigh`. Managed via `GET /api/effort-caps` and `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Custom override for the injected v2 guidance body. Replaces the built-in text; `{{model}}`, `{{effort}}`, and `{{roster}}` placeholders are substituted. Firing gates are unchanged. Settable via `PUT /api/injection-model` (`prompt` key). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Controls only OpenCodex-authored multi-agent developer guidance. Unset/`true` preserves v1/v2 guidance; `false` suppresses both without changing native Codex `[agents]` defaults, the collaboration surface, `subagentModels`, routing, or effort caps. `GET/PUT /api/injection-model` exposes the effective value; PUT is a partial update. | | `disabledModels?` | `string[]` | — | Models **hidden** from Codex's catalog and `/v1/models` (not blocked at the proxy). Routed `provider/model` ids are excluded from listings; bare native GPT slugs (e.g. `gpt-5.4`) flip their catalog entry to `visibility: "hide"` and drop from the bare `/v1/models` list. Exact model ids remain directly callable. Toggleable per model from the dashboard Models page. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 3-state multi-agent surface override. `"v1"` forces all models to the v1 surface (overrides upstream pins); `"default"` respects upstream model pins (sol/terra=v2, luna=v1); `"v2"` forces all models to v2. Settable from the dashboard Models page or `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Per-provider Codex-visible context caps. A cap only lowers known context windows. | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index 7ac97ef495..f9345e147e 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -142,10 +142,11 @@ opencodex добавляет трёхпозиционное переопреде запросом `PUT /api/v2` с `{ "multiAgentMode": "v1" }`. Изменения применяются к новым сессиям Codex. :::caution -На поверхности v2 (`multi_agent_v2`) порождённые подагенты наследуют модель родительской сессии. -Селектор модели/уровня делегирования в дашборде — это подсказка для промпта v1, а не -кросс-модельный маршрутизатор на стороне прокси для каждого порождения. Каноничное поведение -описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). +На поверхности v2 (`multi_agent_v2`) подагент без явно заданной модели может унаследовать модель +родительской сессии. Подсказка OpenCodex может попросить Codex явно передать выбранные модель и +уровень, а отдельная настройка нативных значений по умолчанию применяет их после sync/restart. +Ни один механизм не является маршрутизатором на стороне прокси для каждого порождения. Каноничное +поведение описано в разделе [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: ## Верхние уровни рассуждений @@ -185,9 +186,9 @@ id `provider/model` через `subagentModels` или страницу Subagent присваивает этим записям приоритеты 0–4 в выбранном порядке. Остальные модели по-прежнему можно вызывать по точному id. -Список избранных моделей не связан с подсказкой **Sub-agent delegation** на дашборде. В -частности, переопределения избранных моделей не обходят правило наследования родительской модели -в v2. +Список избранных моделей отделён от выбора **Sub-agent delegation** на дашборде. Он лишь определяет, +какие переопределения Codex показывает первыми, и сам по себе не выбирает модель и не запускает +делегирование. ## Обновление состояния моделей diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index ef3609caa7..27510501d6 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex позволяет выбрать поверхность мульти ### Модель делегирования и уровень рассуждений -Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Это настройки инструкции по делегированию, а не маршрутизатор порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. +Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, `injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Они не задают маршрутизацию порождений на стороне прокси. Необязательный `injectionPrompt` полностью заменяет встроенный текст инструкции. + +Если явно включить `syncCodexSubagentDefaults`, следующая синхронизация или перезапуск применит выбранные модель и уровень как нативные значения по умолчанию для подагентов Codex в `[agents]`, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Эти значения действуют только для вновь создаваемых задач Codex и сами по себе не запускают делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. `multiAgentGuidanceText` определяет поверхность по инструментам запроса — включая WebSocket-путь Codex Desktop (`responses_lite`), где инструменты приходят внутри входного элемента `additional_tools`, а не в массиве `tools` запроса. @@ -56,7 +58,7 @@ opencodex позволяет выбрать поверхность мульти - **Dashboard** → первая ячейка статистики: нажмите **v1**, **base** или **v2**. - Страница **Models** → сегментированный переключатель в верхнем ряду. - На обеих страницах есть кнопка **?**, открывающая модальное окно справки со ссылкой на эту страницу. -- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. Включите **Использовать как нативные значения по умолчанию для подагентов Codex**, чтобы после следующей синхронизации или перезапуска применять тот же выбор к новым задачам Codex, когда активной маршрутизацией управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Этот переключатель не зависит от переключателя руководства по делегированию. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# Синхронизировать выбранные значения с нативными значениями подагентов Codex по умолчанию (нужна модель) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # Задать пользовательский промпт инструкции (плейсхолдеры {{model}}/{{effort}}/{{roster}}) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. В PUT пропуск `effort` или `prompt` сохраняет текущее значение, `null` очищает его, а очистка `model` всегда очищает и уровень. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. +`GET /api/injection-model` возвращает `model`, `effort`, `prompt`, `multiAgentGuidanceEnabled`, `syncCodexSubagentDefaults`, глобальную шкалу `efforts` и включённые нативные/маршрутизируемые модели в `available`. PUT является частичным обновлением: пропуск `effort` или `prompt` сохраняет текущее значение, а `null` очищает его. Для `syncCodexSubagentDefaults: true` требуется выбранная модель; очистка `model` всегда очищает уровень и отключает синхронизацию нативных значений по умолчанию. API валидирует уровень по глобальной шкале Codex; Codex дополнительно валидирует уровень порождения по целевой записи каталога. ## Уровень рассуждений -Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. +Необязательная настройка уровня рассуждений подагента хранится как `injectionEffort` и имеет смысл только вместе с моделью внедрения. Она добавляет указание `reasoning_effort` во внедряемую инструкцию v2 и не меняет уровень рассуждений родительской сессии. При включённом `syncCodexSubagentDefaults` и маршрутизации под управлением OpenCodex после следующей синхронизации или перезапуска она также становится нативным уровнем подагента по умолчанию для новых задач Codex. При любом форке, допускающем переопределения, Codex напрямую применяет `reasoning_effort`, переданный в `spawn_agent`. `ultra` стоит выше `max` в каталоге Codex и добавляет семантику автоматического делегирования, но никогда не доходит до провайдера как буквальное значение в запросе. Codex преобразует `ultra` в `max` на границе клиента. Затем opencodex сохраняет запрос к провайдеру валидным: diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 70d8e9e22a..e94f39396e 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -26,7 +26,7 @@ bun run dev:gui | Раздел | Что делает | | --- | --- | | **Сводка Dashboard** | Мультиагентный режим, состояние онлайн, версия, время работы, число провайдеров, сумма токенов за 30 дней, активные провайдеры и доступные нативные/маршрутизируемые модели. | -| **Sub-agent delegation** | Выбор нативной или маршрутизируемой модели для инструкции и необязательного уровня рассуждений для v1-промптов делегирования. Это не маршрутизатор для каждого порождения; см. ниже. | +| **Sub-agent delegation** | Выбор нативной/маршрутизируемой модели и необязательного уровня рассуждений, общих для руководства OpenCodex по делегированию и опциональных нативных значений подагентов Codex по умолчанию. Это не маршрутизатор отдельных порождений; см. ниже. | | **Сайдкары** | Выбор модели и уровня рассуждений для веб-поиска, а также модели описания изображений. Изменения применяются со следующего запроса. | | **Maintenance** | Пересинхронизация каталога моделей Codex, просмотр предупреждений об обходе через проектную локальную конфигурацию, проверка последнего или предварительного выпуска и запуск обновления с необязательным перезапуском прокси. | | **Безопасность запуска** | Показывает, сохранит ли внедрённая маршрутизация Codex работоспособность после перезагрузки, отдельно отображая службу, launcher shim и точные команды исправления. | @@ -57,15 +57,22 @@ bun run dev:gui ## Селектор делегирования и маршрутизация порождений Селектор **Sub-agent delegation** в дашборде сохраняет `injectionModel` и, при желании, -`injectionEffort`. В ходах v1 opencodex внедряет инструкцию, сообщающую родительскому агенту, какие -именно модель и уровень рассуждений передать в `spawn_agent`. Выбор модели включает эту инструкцию -при любом уровне рассуждений родителя; очистка модели очищает и сохранённый уровень. +`injectionEffort`. Выбранные значения используются в добавляемом OpenCodex руководстве по +делегированию, которое отдельно управляется полем `multiAgentGuidanceEnabled`. Очистка модели также +очищает сохранённый уровень и отключает синхронизацию нативных значений по умолчанию. + +Если включить **Использовать как нативные значения подагентов Codex по умолчанию**, следующая +синхронизация или перезапуск применит выбранные модель и уровень как нативные значения `[agents]`, +когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация +провайдера остаётся неизменной. Они действуют только для вновь создаваемых задач Codex, и эта настройка сама по себе не запускает +делегирование. Существующие пользовательские значения `[agents]` не перезаписываются, а сохраняются, +поэтому запрошенные и фактические значения Codex по умолчанию могут различаться. :::caution -Этот селектор — инструкция делегирования для поверхности совместимости v1. На `multi_agent_v2` -текущий прокси не добавляет v1-сообщение внедрения, и каждый порождённый подагент наследует модель -родительской сессии. Это не кросс-модельный маршрутизатор на стороне прокси. Каноничное поведение -v1/base/v2 описано на странице +Эти два переключателя независимы. Отключение руководства OpenCodex по делегированию не отключает +синхронизацию нативных значений по умолчанию; включение синхронизации не включает руководство и не +вызывает делегирование. Ни одна из настроек не является кросс-модельным маршрутизатором отдельных +порождений на стороне прокси. Каноничное поведение v1/base/v2 описано на странице [Поверхность подагентов](/ru/guides/sub-agent-surface/). ::: @@ -102,7 +109,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | Проверка, запуск и мониторинг задач самообновления. | | `GET` / `PUT /api/sidecar-settings` | Чтение или настройка моделей сайдкаров поиска/vision. | -| `GET` / `PUT /api/injection-model` | Чтение или настройка модели v1-инструкции делегирования и необязательного уровня. | +| `GET` / `PUT /api/injection-model` | Чтение или настройка модели/уровня руководства по делегированию, его переключателя и переключателя синхронизации нативных значений подагентов Codex по умолчанию. | | `GET` / `PUT /api/v2` | Чтение или настройка режима поверхности, фиче-флага Codex и лимита потоков v2. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | Список, добавление/замена, включение/отключение или удаление провайдеров. | | `GET /api/models` · `PUT /api/disabled-models` | Список строк нативных/маршрутизируемых моделей и обновление общего набора отключённых моделей. | diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 7e5457c12e..4d426322fd 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -36,12 +36,13 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `openaiProviderTierVersion?` | `2` | задаётся миграцией | Отмечает, что единая проекция OpenAI с учётом опций завершена. | | `defaultProvider` | `string` | `"openai"` | Провайдер, используемый, когда маршрутизация не находит лучшего совпадения. | | `subagentModels?` | `string[]` | `gpt-5.5`, тройка GPT-5.6, `gpt-5.4-mini` | До 5 нативных slug или id вида `provider/model`, отображаемых первыми в селекторе подагентов Codex. Список в руководстве v2 — пересечение настроенных моделей с первыми пятью видимыми в селекторе, совместимыми с v2 и отсортированными по priority записями Codex; используются канонические slug каталога и доступные уровни effort, а исключённые элементы остаются в конфигурации. Явно заданный пустой список сохраняется. | -| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель, указываемая во внедряемом multi-agent-руководстве (поверхность v2); руководству по делегированию предписывается передавать именно эту модель в `spawn_agent` с `fork_turns: "none"`. | -| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений для `spawn_agent` (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`. | +| `injectionModel?` | `string` | — | Предпочитаемая нативная или маршрутизируемая модель подагента. Она используется в v2-руководстве по делегированию от OpenCodex, отдельно управляемом `multiAgentGuidanceEnabled`, а опциональное `syncCodexSubagentDefaults` также может сделать её нативным значением Codex по умолчанию для новых задач. | +| `injectionEffort?` | `string` | — | Предпочитаемый уровень рассуждений подагента (от `low` до `ultra`). Имеет смысл только вместе с `injectionModel`; используется руководством по делегированию и, при явном согласии, нативным значением Codex по умолчанию. | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | Явное согласие применить выбранные `injectionModel` / `injectionEffort` как нативные значения подагентов Codex по умолчанию в `[agents]` при следующей синхронизации или перезапуске, когда активной маршрутизацией Codex управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Они влияют только на вновь создаваемые задачи Codex и сами по себе не запускают делегирование. Существующие пользовательские целевые записи сохраняются как конфликты, а не перезаписываются. Требует `injectionModel`; очистка модели отключает это поле. Доступно как поле частичного обновления `GET/PUT /api/injection-model`. | | `effortCap?` | `string` | — | Жёсткий потолок уровня рассуждений на каждый запрос. Функция multi-agent V2: применяется к основным ходам, чей собственный список инструментов несёт поверхность совместной работы V2, а также к ходам порождённых потомков, помеченным ровно `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata` (помеченные потомки подпадают под потолок независимо от их собственной поверхности инструментов). Обычные основные ходы и основные ходы с поверхностью V1 не затрагиваются, ходы compaction всегда обходят потолки, а `multiAgentMode: "v1"` полностью отключает потолки (дашборд скрывает панель). Принимает значения от `low` до `ultra`; потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `subagentEffortCap?` | `string` | — | Тот же жёсткий потолок, применяемый только к ходам порождённых потомков, идентифицированным маркерами codex-rs с точным совпадением: `x-openai-subagent: collab_spawn` или `"subagent_kind": "thread_spawn"` в `x-codex-turn-metadata`. Другие внутренние категории подагентов (ревью, compaction, консолидация памяти) никогда не подпадают под этот потолок, а `multiAgentMode: "v1"` полностью его отключает. Принимает значения от `low` до `ultra`; когда заданы оба потолка, действует более низкий, и потолки только понижают уровень, никогда не повышают. Уровень опускается до самой высокой поддерживаемой ступени, не превышающей потолок. Если модель не предоставляет управление уровнем рассуждений или под потолком нет ни одной поддерживаемой ступени, поле уровня удаляется и действует значение провайдера по умолчанию. `max` и `ultra` принимаются, но не задают потолок более низкого ранга (после клиентского преобразования `ultra` → `max` запросы приходят со значениями от `low` до `max`), хотя известные лестницы моделей всё же могут вызвать понижение ступени или удаление поля. Селектор в дашборде предлагает значения от `low` до `xhigh`. Управляется через `GET /api/effort-caps` и `PUT /api/effort-caps`. | | `injectionPrompt?` | `string` | — | Пользовательская замена текста внедряемого v2-руководства. Заменяет встроенный текст; плейсхолдеры `{{model}}`, `{{effort}}` и `{{roster}}` подставляются. Условия срабатывания не меняются. Настраивается через `PUT /api/injection-model` (ключ `prompt`). | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | Управляет только developer-руководством multi-agent, добавляемым OpenCodex. Отсутствующее значение/`true` сохраняет руководство v1/v2; `false` подавляет оба варианта, не меняя нативные значения `[agents]` по умолчанию, поверхность совместной работы, `subagentModels`, маршрутизацию и пределы effort. `GET/PUT /api/injection-model` возвращает эффективное значение; PUT является частичным обновлением. | | `disabledModels?` | `string[]` | — | Модели, скрываемые от Codex. Маршрутизируемые id `provider/model` исключаются из каталога и `/v1/models`; «голые» нативные GPT-slug (например, `gpt-5.4`) переводят свою запись каталога в `visibility: "hide"` и исчезают из «голого» списка `/v1/models`. Переключается для каждой модели на странице Models дашборда. | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | Трёхпозиционное переопределение multi-agent-поверхности. `"v1"` принудительно переводит все модели на поверхность v1 (перекрывает вышестоящие привязки); `"default"` учитывает вышестоящие привязки моделей (sol/terra=v2, luna=v1); `"v2"` принудительно переводит все модели на v2. Настраивается на странице Models дашборда или через `ocx v2 mode`. | | `providerContextCaps?` | `Record` | `{}` | Видимые Codex лимиты контекста по провайдерам. Лимит только понижает известные контекстные окна. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 15f4c90e3e..9fda555286 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -108,8 +108,9 @@ opencodex 为每个目录条目的 `multi_agent_version` 提供三态 override `{ "multiAgentMode": "v1" }` 的 `PUT /api/v2` 设置该模式。变更从新的 Codex session 开始生效。 :::caution -在 v2(`multi_agent_v2`)界面中,生成的子代理会继承父 session 的模型。仪表盘中的委派模型/ -reasoning 选择器只是 v1 prompt 指引,并不是由代理在每次生成时执行跨模型路由。权威说明见 +在 v2(`multi_agent_v2`)界面中,未显式指定模型的子代理可能继承父 session 的模型。 +OpenCodex 指引可以要求 Codex 显式传递所选模型/reasoning,独立的原生默认值开关则可在 +sync/restart 后提供默认值。两者都不是由代理在每次生成时执行的代理侧路由。权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -145,8 +146,8 @@ override。你可以通过 `subagentModels` 或仪表盘的 Subagents 页面选 `provider/model` id;opencodex 会按所选顺序赋予它们 0-4 的 priority。其他模型仍可通过精确 id 直接调用。 -置顶模型列表与 Dashboard 的 **Sub-agent delegation** 指引相互独立。尤其需要注意,置顶模型 -override 不能绕过 v2 的父模型继承规则。 +置顶模型列表与 Dashboard 的 **Sub-agent delegation** 选择相互独立。它只决定 Codex 优先显示 +哪些 override,本身不会选择模型或触发委派。 ## 刷新模型状态 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index e47bd70641..ad2828a28a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -39,7 +39,9 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 ### 委托模型与推理强度 -仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。它们用于生成委托指引,并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 +仪表盘中的 **子代理委托** 选择器会保存 `injectionModel`,以及可选的 `injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 `multiAgentGuidanceEnabled` 单独控制。它们并不是由 proxy 执行的子代理路由规则。设置 `injectionPrompt` 可以把内置指引文本整体替换为自定义内容。 + +显式启用 `syncCodexSubagentDefaults` 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把所选模型和 effort 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该设置本身不会触发委派。已有的用户自有 `[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 `multiAgentGuidanceText` 根据请求中的工具列表判断当前界面 —— 包括 Codex Desktop 的 WebSocket 路径(`responses_lite`),此时工具位于 `additional_tools` input 项中而不是请求的 `tools` 数组。 @@ -56,7 +58,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 - **Models** 页面 → 使用顶部的分段控件。 - 两个页面都有 **?** 按钮,可打开帮助弹窗并返回本文。 -- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把相同选择应用于新建 Codex 任务;外部用户管理的 provider 配置不会被修改。此开关与委派指引开关相互独立。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 ### CLI @@ -92,6 +94,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ -d '{"model": "anthropic/claude-sonnet-5", "effort": "xhigh"}' +# 将所选值同步为 Codex 原生子代理默认值(需先设置 model) +curl -X PUT http://localhost:10100/api/injection-model \ + -H 'Content-Type: application/json' \ + -d '{"model": "anthropic/claude-sonnet-5", "syncCodexSubagentDefaults": true}' + # 设置自定义指引提示词({{model}}/{{effort}}/{{roster}} 占位符) curl -X PUT http://localhost:10100/api/injection-model \ -H 'Content-Type: application/json' \ @@ -103,11 +110,11 @@ curl -X PUT http://localhost:10100/api/injection-model \ -d '{"model": null}' ``` -`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 请求省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它;清除 `model` 一定会同时清除推理强度。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 +`GET /api/injection-model` 返回 `model`、`effort`、`prompt`、`multiAgentGuidanceEnabled`、`syncCodexSubagentDefaults`、全局 `efforts` 阶梯,以及由已启用原生/路由模型组成的 `available` 列表。PUT 为部分更新:省略 `effort` 或 `prompt` 时会保留当前值,传入 `null` 时会清除它。`syncCodexSubagentDefaults: true` 要求已经选择模型;清除 `model` 一定会同时清除推理强度,并关闭原生默认值同步。API 会按全局 Codex 阶梯验证推理强度,Codex 仍会在生成时检查目标目录条目是否支持该强度。 ## 推理强度 -可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 +可选的子代理推理强度保存在 `injectionEffort` 中,只有同时设置注入模型时才有意义。它会向注入的 v2 指引加入 `reasoning_effort` 要求,但不会改变父会话的推理强度。启用 `syncCodexSubagentDefaults` 且 OpenCodex 管理当前 Codex 路由时,下一次同步或重启还会把它作为新建 Codex 任务的原生子代理默认 effort。在接受覆盖的 fork 上,Codex 会直接应用传给 `spawn_agent` 的 `reasoning_effort`。 在 Codex 目录中,`ultra` 的级别高于 `max`,并带有自动委托语义;但 provider 永远不会在线路上收到字面量 `ultra`。Codex 会在客户端边界将 `ultra` 转成 `max`,随后 opencodex 再确保 provider 收到有效值: diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index d799eea539..1d698b1db0 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -25,7 +25,7 @@ bun run dev:gui | 区域 | 作用 | | --- | --- | | **Dashboard 摘要** | 显示 multi-agent 模式、在线状态、版本、运行时间、provider 数量、30 天 token 总量、活动 provider 和可用的原生/路由模型。 | -| **Sub-agent delegation** | 为 v1 委派 prompt 选择原生或路由模型,并可指定 reasoning 强度。它不是逐次生成的路由器,详见下文。 | +| **Sub-agent delegation** | 选择供 OpenCodex 委派指引与可选的 Codex 原生子代理默认值共用的原生/路由模型和可选 reasoning 强度。它不是逐次生成的路由器,详见下文。 | | **Sidecar** | 选择 web-search 模型及强度,以及图像描述模型;更改从下一次请求开始生效。 | | **Maintenance** | 重新同步 Codex 模型目录,查看项目级配置绕过警告,检查 latest/preview 版本,并可在更新后重启代理。 | | **启动安全** | 显示注入的 Codex 路由能否在重启后继续工作,并分别显示服务、launcher shim 状态和准确的修复命令。 | @@ -55,13 +55,16 @@ bun run dev:gui ## 委派选择器与生成路由的区别 Dashboard 的 **Sub-agent delegation** 选择器会保存 `injectionModel`,以及可选的 -`injectionEffort`。在 v1 turn 中,opencodex 会注入一段指引,告诉父代理调用 `spawn_agent` 时应 -传入哪个精确模型和 reasoning 强度。只要选定模型,无论父代理当前使用何种 reasoning 强度,都会 -启用这段指引;清除模型时也会清除已保存的强度。 +`injectionEffort`。所选值会用于由 OpenCodex 编写的委派指引,而该指引由 +`multiAgentGuidanceEnabled` 单独控制。清除模型时也会清除已保存的强度,并关闭原生默认值同步。 + +启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会 +把所选模型和强度应用为原生 `[agents]` 默认值;外部用户管理的 provider 配置不会被修改。这些默认值只影响新建的 Codex 任务,该选项本身不会触发委派。已有的用户自有 +`[agents]` 默认值会保留而不会被覆盖,因此请求的默认值可能与 Codex 实际使用的默认值不同。 :::caution -该选择器是面向 v1 兼容界面的委派指引。在 `multi_agent_v2` 中,当前代理不会附加 v1 注入消息, -而且所有生成的子代理都会继承父 session 的模型。它不是代理侧的跨模型路由器。v1/base/v2 的 +两个开关相互独立:关闭 OpenCodex 委派指引不会关闭原生默认值同步;启用原生默认值同步也不会 +启用委派指引或触发委派。两者都不是代理侧的逐次跨模型路由器。v1/base/v2 的 权威说明见 [子代理界面](/zh-cn/guides/sub-agent-surface/)。 ::: @@ -94,7 +97,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | | `GET /api/update/check` · `POST /api/update/run` · `GET /api/update/status` | 检查、运行和监控自更新任务。 | | `GET` / `PUT /api/sidecar-settings` | 读取或设置 search/vision sidecar 模型。 | -| `GET` / `PUT /api/injection-model` | 读取或设置 v1 委派指引模型及可选强度。 | +| `GET` / `PUT /api/injection-model` | 读取或设置委派指引模型/强度、指引开关及 Codex 原生子代理默认值同步开关。 | | `GET` / `PUT /api/v2` | 读取或设置界面模式、Codex feature flag 和 v2 thread 上限。 | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | 列出、添加/替换、启用/禁用或删除 provider。 | | `GET /api/models` · `PUT /api/disabled-models` | 列出原生/路由模型,并更新共享的 disabled-model 集合。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 8b9e673342..f41c59aea3 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -30,12 +30,13 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `openaiProviderTierVersion?` | `2` | migration 设置 | 单一选项式 OpenAI projection 完成标记。 | | `defaultProvider` | `string` | `"openai"` | 路由找不到更优匹配时使用的 provider。 | | `subagentModels?` | `string[]` | `gpt-5.5`、三款 GPT-5.6、`gpt-5.4-mini` | 最多 5 个原生 slug 或 `provider/model` id,优先显示在 Codex subagent picker 中。v2 指引清单是已配置模型与 Codex 中 picker 可见、兼容 v2、按 priority 排序后前五项的交集,并使用规范目录 slug 与可用 effort 档位;被排除的条目仍保留在配置中。显式空数组会被保留。 | -| `injectionModel?` | `string` | — | 注入 multi-agent 指南(v2 界面)的首选原生或路由模型;委派指南会要求把该模型连同 `fork_turns: "none"` 一起传给 `spawn_agent`。 | -| `injectionEffort?` | `string` | — | 首选 `spawn_agent` reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义。 | +| `injectionModel?` | `string` | — | 首选原生或路由子代理模型。它用于由独立 `multiAgentGuidanceEnabled` 控制、OpenCodex 编写的 v2 委派指引;选择 `syncCodexSubagentDefaults` 后,也可将其用作新任务的 Codex 原生默认值。 | +| `injectionEffort?` | `string` | — | 首选子代理 reasoning effort(`low` 到 `ultra`)。只有与 `injectionModel` 一起使用才有意义;它用于委派指引,并可在选择同步后作为 Codex 原生默认值。 | +| `syncCodexSubagentDefaults?` | `boolean` | `false` | 当 OpenCodex 管理当前 Codex 路由时,选择是否在下一次同步或重启把已选的 `injectionModel` / `injectionEffort` 应用为 Codex 原生 `[agents]` 子代理默认值。外部用户管理的 provider 配置不会被修改。这些默认值只影响新建 Codex 任务,本身不会触发委派。已有的用户自有目标条目会作为冲突保留而不会被覆盖。此字段要求 `injectionModel`;清除模型会同时关闭该选项。它作为 `GET/PUT /api/injection-model` 的部分更新字段提供。 | | `effortCap?` | `string` | — | reasoning effort 的逐请求硬上限。这是多代理 V2 专属功能:适用于工具列表带有 V2 协作表面的主轮次,以及标记精确匹配 `x-openai-subagent: collab_spawn` 或 `x-codex-turn-metadata` 中 `"subagent_kind": "thread_spawn"` 的派生子轮次(带标记的子轮次无论自身工具表面如何都会被覆盖)。普通主轮次与 V1 表面主轮次不受影响,压缩(compaction)轮次始终绕过上限,`multiAgentMode: "v1"` 会完全禁用上限功能(仪表盘同时隐藏该面板)。接受 `low` 到 `ultra`;只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `subagentEffortCap?` | `string` | — | 同样的硬上限,但只用于 codex-rs 标记精确匹配的派生子轮次:`x-openai-subagent: collab_spawn`,或 `x-codex-turn-metadata` 中的 `"subagent_kind": "thread_spawn"`。其他内部子代理类别(评审、压缩、记忆整理)不会触发此上限,`multiAgentMode: "v1"` 会完全禁用该功能。接受 `low` 到 `ultra`;两个上限同时设置时取较低者,且只会降低 effort,绝不会提高。会降至不高于上限的最高受支持档位。若模型不提供 effort 控制,或上限之下没有可用档位,则移除 effort 字段并采用 provider 默认值。`max` 和 `ultra` 均可使用,但不会形成更低的等级上限(客户端会将 `ultra` 转换为 `max`,因此请求以 `low` 到 `max` 的范围到达);不过,已知的模型 effort 阶梯仍可能触发降档或移除字段。仪表盘选择器提供 `low` 到 `xhigh`。通过 `GET /api/effort-caps` 和 `PUT /api/effort-caps` 管理。 | | `injectionPrompt?` | `string` | — | 整体替换注入的 v2 指南正文的自定义文本。`{{model}}`、`{{effort}}`、`{{roster}}` 占位符会被替换,触发条件保持不变。也可通过 `PUT /api/injection-model` 的 `prompt` 键设置。 | -| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | +| `multiAgentGuidanceEnabled?` | `boolean` | `true` | 仅控制由 OpenCodex 添加的 multi-agent developer 指引。未设置/`true` 保持 v1/v2 指引;`false` 会同时禁止两者,但不改变 Codex 原生 `[agents]` 默认值、协作界面、`subagentModels`、路由或 effort 上限。`GET/PUT /api/injection-model` 返回有效值,PUT 为部分更新。 | | `disabledModels?` | `string[]` | — | 从 Codex 隐藏的模型。路由 `provider/model` id 会从目录和 `/v1/models` 排除;bare 原生 GPT slug(如 `gpt-5.4`)的目录条目会改成 `visibility: "hide"`,并从 bare `/v1/models` 列表移除。可在仪表盘 Models 页面按模型切换。 | | `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | 三态 multi-agent surface override。`"v1"` 覆盖 upstream pin,强制全部模型使用 v1;`"default"` 遵循 upstream model pin(sol/terra=v2,luna=v1);`"v2"` 强制全部模型使用 v2。可在仪表盘 Models 页面或 `ocx v2 mode` 中设置。 | | `providerContextCaps?` | `Record` | `{}` | provider 级 Codex 可见 context cap。只会降低已知 context window。 | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 81a38269c9..d60e839b89 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -21,6 +21,7 @@ import { installApiAuthFetch } from "./api"; import { readJsonIfOk } from "./fetch-json"; import { type Page } from "./app-routing"; import { useAppRouteState } from "./use-app-route-state"; +import { requestProxyStop } from "./stop-proxy"; installApiAuthFetch(); @@ -185,17 +186,16 @@ export default function App() { const handleStop = async () => { if (!confirm(t("dash.stopConfirm"))) return; setStopping(true); - try { - const res = await fetch(`${API_BASE}/api/stop`, { method: "POST" }); - // A refusal (409: a service under another home owns this proxy) returns normally instead - // of dropping the connection, so the button would otherwise sit in "stopping…" forever - // with nothing explaining why. - if (!res.ok) { - setStopping(false); - const detail = await res.json().catch(() => null) as { message?: string } | null; - if (detail?.message) alert(detail.message); - } - } catch { /* connection drops — the proxy is going down as expected */ } + const outcome = await requestProxyStop(API_BASE, { + formatFailure: status => t("dash.stopFailed", { status: String(status) }), + }); + // Refusals and restore failures return normally instead of dropping the connection. + // In both cases the proxy did not reach a clean-stop result, so re-enable the control + // and surface the server's remediation instead of leaving "stopping…" stuck forever. + if (!outcome.accepted) { + setStopping(false); + alert(outcome.message); + } }; const brand = ( diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d206e2c2d1..7f90e940a2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -149,6 +149,7 @@ export const de: Record = { "dash.runStart": "Führe {cmd} aus, um den Proxy zu starten.", "dash.stop": "Proxy stoppen", "dash.stopConfirm": "Proxy stoppen und natives Codex wiederherstellen?", + "dash.stopFailed": "Proxy konnte nicht gestoppt werden (HTTP {status}).", "dash.stopping": "Wird gestoppt…", "dash.codexAutoStart": "opencodex mit Codex starten", "dash.codexAutoStartHint": "Erlaubt einem installierten Launcher-Shim, ocx ensure auszuführen. Diese Einstellung installiert keinen Neustartschutz; prüfe den effektiven Zustand unter Startsicherheit.", @@ -177,10 +178,11 @@ export const de: Record = { "dash.sidecarSaved": "Sidecar-Einstellungen gespeichert. Angewendet bei der nächsten Anfrage.", "dash.sidecarSaveFailed": "Sidecar-Einstellungen konnten nicht gespeichert werden.", "dash.injectionLabel": "Sub-Agent-Delegation", - "dash.injectionHint": "Wähle ein geroutetes Modell für den Delegations-Prompt. Der Agent wird angewiesen, es für Teilaufgaben zu nutzen.", + "dash.injectionHint": "Wähle das Modell und den optionalen Aufwand, die von den beiden Steuerelementen unten gemeinsam verwendet werden.", + "dash.syncCodexSubagentDefaults": "Als native Codex-Subagent-Standardwerte verwenden", + "dash.syncCodexSubagentDefaultsHint": "Standardmäßig deaktiviert. Wenn OpenCodex das Codex-Routing verwaltet, wendet eine Synchronisierung oder ein Neustart das ausgewählte Modell und den Aufwand als native Codex-[agents]-Standardwerte für neue Codex-Aufgaben an. Dies löst keine Delegation aus; vorhandene benutzereigene [agents]-Standardwerte bleiben erhalten und werden nicht überschrieben.", "dash.multiAgentGuidance": "OpenCodex-Multi-Agent-Anleitung", - "dash.multiAgentGuidanceHint": "Fügt von OpenCodex erstellte Delegationshinweise hinzu. Beim Ausschalten bleiben v1/v2-Oberfläche, Sub-Agent-Liste, Routing und Aufwandsgrenzen unverändert.", - "dash.injectionActive": "Aktiv", + "dash.multiAgentGuidanceHint": "Fügt von OpenCodex erstellte Delegationshinweise hinzu. Dies ist von den nativen Codex-Standards oben getrennt und ändert weder v1/v2-Oberfläche, Sub-Agent-Liste, Routing noch Aufwandsgrenzen.", "dash.injectionNone": "Keine", "dash.injectionEffortLabel": "Reasoning-Aufwand", "dash.injectionEffortNone": "Modell-Standard", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a2b07434d2..cf56313086 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -159,6 +159,7 @@ export const en = { "dash.runStart": "Run {cmd} to start the proxy.", "dash.stop": "Stop Proxy", "dash.stopConfirm": "Stop the proxy and restore native Codex?", + "dash.stopFailed": "Failed to stop proxy (HTTP {status}).", "dash.stopping": "Stopping…", "dash.codexAutoStart": "Start opencodex with Codex", "dash.codexAutoStartHint": "Allows an installed launcher shim to run ocx ensure. This setting does not install restart protection; check Startup safety for the effective state.", @@ -187,10 +188,11 @@ export const en = { "dash.sidecarSaved": "Sidecar settings saved. Applied on the next request.", "dash.sidecarSaveFailed": "Failed to save sidecar settings.", "dash.injectionLabel": "Sub-agent delegation", - "dash.injectionHint": "Pick a routed model to inject into the delegation prompt. The agent will be told to use it for sub-tasks.", + "dash.injectionHint": "Choose the model and optional effort shared by the two controls below.", + "dash.syncCodexSubagentDefaults": "Use as native Codex subagent defaults", + "dash.syncCodexSubagentDefaultsHint": "Off by default. When OpenCodex manages Codex routing, sync or restart applies the selected model and effort as native Codex [agents] defaults for new Codex tasks. This does not cause delegation; existing user-owned [agents] defaults are preserved rather than overwritten.", "dash.multiAgentGuidance": "OpenCodex multi-agent guidance", - "dash.multiAgentGuidanceHint": "Adds OpenCodex-authored delegation instructions. Turning this off keeps the v1/v2 surface, sub-agent roster, routing, and effort caps unchanged.", - "dash.injectionActive": "Active", + "dash.multiAgentGuidanceHint": "Adds OpenCodex-authored delegation instructions. This is separate from the native Codex defaults above and does not change the v1/v2 surface, sub-agent roster, routing, or effort caps.", "dash.injectionNone": "None", "dash.injectionEffortLabel": "Reasoning effort", "dash.injectionEffortNone": "Model default", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d27de24152..6362fb8eb1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -158,6 +158,7 @@ export const ja: Record = { "dash.runStart": "{cmd} を実行してプロキシを起動してください。", "dash.stop": "プロキシを停止", "dash.stopConfirm": "プロキシを停止してネイティブの Codex に戻しますか?", + "dash.stopFailed": "プロキシを停止できませんでした (HTTP {status})。", "dash.stopping": "停止中…", "dash.codexAutoStart": "Codex と一緒に opencodex を起動", "dash.codexAutoStartHint": "インストール済み launcher shim に ocx ensure の実行を許可します。この設定だけでは再起動保護はインストールされません。起動安全性で実際の状態を確認してください。", @@ -186,10 +187,11 @@ export const ja: Record = { "dash.sidecarSaved": "サイドカー設定を保存しました。次回リクエスト時に適用されます。", "dash.sidecarSaveFailed": "サイドカー設定の保存に失敗しました。", "dash.injectionLabel": "サブエージェント委任", - "dash.injectionHint": "委任プロンプトに注入するルーティングモデルを選択します。エージェントはサブタスクにそれを使うよう指示されます。", + "dash.injectionHint": "下の 2 つの設定で共用するモデルと任意の推論負荷を選択します。", + "dash.syncCodexSubagentDefaults": "ネイティブ Codex サブエージェントの既定値として使用", + "dash.syncCodexSubagentDefaultsHint": "既定ではオフです。OpenCodex が Codex ルーティングを管理している場合、同期または再起動によって選択したモデルと推論負荷を新しい Codex タスクのネイティブ Codex [agents] 既定値として適用します。この設定自体は委任を発生させず、既存のユーザー所有 [agents] 既定値は上書きせずに保持します。", "dash.multiAgentGuidance": "OpenCodex マルチエージェントガイダンス", - "dash.multiAgentGuidanceHint": "OpenCodex が作成する委任指示を追加します。オフにしても v1/v2 サーフェス、サブエージェントロスター、ルーティング、effort 上限は変わりません。", - "dash.injectionActive": "アクティブ", + "dash.multiAgentGuidanceHint": "OpenCodex が作成する委任指示を追加します。上のネイティブ Codex 既定値とは別で、v1/v2 サーフェス、サブエージェントロスター、ルーティング、effort 上限は変わりません。", "dash.injectionNone": "なし", "dash.injectionEffortLabel": "推論負荷", "dash.injectionEffortNone": "モデル既定", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1257596e93..f3f8fb3e20 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -153,6 +153,7 @@ export const ko: Record = { "dash.runStart": "{cmd} 를 실행해 프록시를 시작하세요.", "dash.stop": "프록시 중지", "dash.stopConfirm": "프록시를 중지하고 Codex 원본 설정을 복원할까요?", + "dash.stopFailed": "프록시를 중지하지 못했습니다 (HTTP {status}).", "dash.stopping": "중지 중…", "dash.codexAutoStart": "Codex 실행 시 opencodex 시작", "dash.codexAutoStartHint": "설치된 launcher shim이 ocx ensure를 실행하도록 허용합니다. 이 설정은 재부팅 보호를 설치하지 않으므로 시작 안전성에서 실제 상태를 확인하세요.", @@ -181,10 +182,11 @@ export const ko: Record = { "dash.sidecarSaved": "사이드카 설정이 저장됐습니다. 다음 요청부터 적용됩니다.", "dash.sidecarSaveFailed": "사이드카 설정 저장에 실패했습니다.", "dash.injectionLabel": "서브에이전트 위임", - "dash.injectionHint": "위임 프롬프트에 주입할 라우팅 모델을 선택합니다. 에이전트가 서브태스크에 이 모델을 사용하도록 안내됩니다.", + "dash.injectionHint": "아래 두 제어가 함께 사용할 모델과 선택적인 추론 강도를 고릅니다.", + "dash.syncCodexSubagentDefaults": "네이티브 Codex 서브에이전트 기본값으로 사용", + "dash.syncCodexSubagentDefaultsHint": "기본적으로 꺼져 있습니다. OpenCodex가 Codex 라우팅을 관리하는 경우 동기화하거나 재시작하면 선택한 모델과 추론 강도를 새 Codex 작업의 네이티브 Codex [agents] 기본값으로 적용합니다. 이 설정 자체가 위임을 일으키지는 않으며, 기존 사용자 소유 [agents] 기본값은 덮어쓰지 않고 보존합니다.", "dash.multiAgentGuidance": "OpenCodex 멀티 에이전트 가이던스", - "dash.multiAgentGuidanceHint": "OpenCodex가 작성한 위임 안내를 추가합니다. 꺼도 v1/v2 표면, 서브에이전트 로스터, 라우팅, effort 상한은 바뀌지 않습니다.", - "dash.injectionActive": "활성", + "dash.multiAgentGuidanceHint": "OpenCodex가 작성한 위임 안내를 추가합니다. 위의 네이티브 Codex 기본값과 별개이며 v1/v2 표면, 서브에이전트 로스터, 라우팅, effort 상한은 바꾸지 않습니다.", "dash.injectionNone": "없음", "dash.injectionEffortLabel": "추론 강도", "dash.injectionEffortNone": "모델 기본값", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 82efcb20d0..0b0198c87e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -158,6 +158,7 @@ export const ru: Record = { "dash.runStart": "Выполните {cmd}, чтобы запустить прокси.", "dash.stop": "Остановить прокси", "dash.stopConfirm": "Остановить прокси и восстановить нативный Codex?", + "dash.stopFailed": "Не удалось остановить прокси (HTTP {status}).", "dash.stopping": "Остановка…", "dash.codexAutoStart": "Запускать opencodex вместе с Codex", "dash.codexAutoStartHint": "Разрешает установленному launcher shim выполнять ocx ensure. Эта настройка не устанавливает защиту перезапуска; проверьте фактическое состояние в разделе безопасности запуска.", @@ -186,10 +187,11 @@ export const ru: Record = { "dash.sidecarSaved": "Настройки сайдкара сохранены. Вступят в силу со следующего запроса.", "dash.sidecarSaveFailed": "Не удалось сохранить настройки сайдкара.", "dash.injectionLabel": "Делегирование подагентам", - "dash.injectionHint": "Выберите маршрутизируемую модель для внедрения в промпт делегирования. Агенту будет указано использовать её для подзадач.", + "dash.injectionHint": "Выберите модель и необязательный уровень рассуждений, общие для двух настроек ниже.", + "dash.syncCodexSubagentDefaults": "Использовать как нативные значения подагентов Codex по умолчанию", + "dash.syncCodexSubagentDefaultsHint": "По умолчанию выключено. Если маршрутизацией Codex управляет OpenCodex, синхронизация или перезапуск применяет выбранные модель и уровень рассуждений как нативные значения Codex [agents] для новых задач. Эта настройка сама не вызывает делегирование; существующие пользовательские значения [agents] сохраняются и не перезаписываются.", "dash.multiAgentGuidance": "Мультиагентное руководство OpenCodex", - "dash.multiAgentGuidanceHint": "Добавляет инструкции делегирования от OpenCodex. Отключение не меняет поверхность v1/v2, список подагентов, маршрутизацию и пределы effort.", - "dash.injectionActive": "Активно", + "dash.multiAgentGuidanceHint": "Добавляет инструкции делегирования от OpenCodex. Это отдельная настройка от нативных значений Codex выше; она не меняет поверхность v1/v2, список подагентов, маршрутизацию и пределы effort.", "dash.injectionNone": "Нет", "dash.injectionEffortLabel": "Уровень рассуждений", "dash.injectionEffortNone": "По умолчанию для модели", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 796724fc1d..541cdb9027 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -153,6 +153,7 @@ export const zh: Record = { "dash.runStart": "运行 {cmd} 以启动代理。", "dash.stop": "停止代理", "dash.stopConfirm": "停止代理并恢复原生 Codex 配置?", + "dash.stopFailed": "无法停止代理 (HTTP {status})。", "dash.stopping": "正在停止…", "dash.codexAutoStart": "随 Codex 启动 opencodex", "dash.codexAutoStartHint": "允许已安装的 launcher shim 运行 ocx ensure。此设置不会安装重启保护;请在启动安全中检查实际状态。", @@ -181,10 +182,11 @@ export const zh: Record = { "dash.sidecarSaved": "附属设置已保存。将在下一个请求时生效。", "dash.sidecarSaveFailed": "保存附属设置失败。", "dash.injectionLabel": "子代理委托", - "dash.injectionHint": "选择要注入委托提示的路由模型。代理将被告知在子任务中使用它。", + "dash.injectionHint": "选择供下方两个控件共用的模型和可选推理强度。", + "dash.syncCodexSubagentDefaults": "用作原生 Codex 子代理默认值", + "dash.syncCodexSubagentDefaultsHint": "默认关闭。当 OpenCodex 管理 Codex 路由时,同步或重启会将所选模型和推理强度应用为新 Codex 任务的原生 Codex [agents] 默认值。此设置本身不会触发委托;现有的用户自有 [agents] 默认值会保留而不会被覆盖。", "dash.multiAgentGuidance": "OpenCodex 多代理指引", - "dash.multiAgentGuidanceHint": "添加由 OpenCodex 编写的委派指令。关闭后仍保留 v1/v2 界面、子代理清单、路由和 effort 上限。", - "dash.injectionActive": "已激活", + "dash.multiAgentGuidanceHint": "添加由 OpenCodex 编写的委托指引。它与上方的原生 Codex 默认值相互独立,不会更改 v1/v2 界面、子代理清单、路由或 effort 上限。", "dash.injectionNone": "无", "dash.injectionEffortLabel": "推理强度", "dash.injectionEffortNone": "模型默认", diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index fc127e3354..c43cae4eb8 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -19,12 +19,29 @@ import { export type InjectionPoll = { multiAgentGuidanceEnabled: boolean; + syncCodexSubagentDefaults: boolean; injectionModel: string; injectionEffort: string; injectionEfforts: string[]; injectionAvailable: Array<{ provider: string; model: string; namespaced: string }>; }; +export type InjectionSelectionResponse = { + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; +}; + +export function normalizeInjectionSelection(data: InjectionSelectionResponse) { + return { + multiAgentGuidanceEnabled: data.multiAgentGuidanceEnabled !== false, + syncCodexSubagentDefaults: data.syncCodexSubagentDefaults === true, + injectionModel: data.model ?? "", + injectionEffort: data.effort ?? "", + }; +} + export type EffortCapPoll = { effortCap: string; subagentEffortCap: string; @@ -197,17 +214,12 @@ export async function fetchDashboardCore( try { const imRes = await fetch(`${apiBase}/api/injection-model`, { signal }); if (imRes.ok) { - const imData = await imRes.json() as { - multiAgentGuidanceEnabled?: boolean; - model?: string | null; - effort?: string | null; + const imData = await imRes.json() as InjectionSelectionResponse & { efforts?: string[]; available?: InjectionPoll["injectionAvailable"]; }; injection = { - multiAgentGuidanceEnabled: imData.multiAgentGuidanceEnabled !== false, - injectionModel: imData.model ?? "", - injectionEffort: imData.effort ?? "", + ...normalizeInjectionSelection(imData), injectionEfforts: imData.efforts ?? [], injectionAvailable: imData.available ?? [], }; diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index fb12ceb0cc..9447a30d5f 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -86,12 +86,11 @@ export function DashboardEffortCapPanel({ apiBase, d }: { apiBase: string; d: Da ); } -export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Dash }) { +export function DashboardInjectionPanel({ d }: { apiBase: string; d: Dash }) { const { t, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, - setInjectionModel, setInjectionEffort, setInjectionSaving, - multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled, + multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection, } = d; return ( @@ -104,22 +103,8 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da { value: "", label: t("dash.injectionNone") }, ...injectionAvailable.map(m => ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })), ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: v || null, effort: injectionEffort || null }), - }); - const data = await requireJson<{ model?: string | null; effort?: string | null }>(res); - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} + onChange={(v) => { void saveInjection({ model: v || null, effort: injectionEffort || null }); }} + disabled={injectionSaving} label={t("dash.injectionLabel")} /> {injectionModel && injectionEfforts.length > 0 && ( @@ -129,28 +114,29 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da { value: "", label: t("dash.injectionEffortNone") }, ...injectionEfforts.map(e => ({ value: e, label: e })), ]} - onChange={async (v) => { - if (injectionSaving) return; - setInjectionSaving(true); - try { - const res = await fetch(`${apiBase}/api/injection-model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: injectionModel || null, effort: v || null }), - }); - const data = await requireJson<{ model?: string | null; effort?: string | null }>(res); - setInjectionModel(data.model ?? ""); - setInjectionEffort(data.effort ?? ""); - } catch { /* ignore */ } - finally { setInjectionSaving(false); } - }} - disabled={injectionSaving || !multiAgentGuidanceEnabled} + onChange={(v) => { void saveInjection({ model: injectionModel || null, effort: v || null }); }} + disabled={injectionSaving} label={t("dash.injectionEffortLabel")} /> )} - {injectionModel && multiAgentGuidanceEnabled && {t("dash.injectionActive")}}
{t("dash.injectionHint")}
+
+
+
{t("dash.syncCodexSubagentDefaults")}
+
{t("dash.syncCodexSubagentDefaultsHint")}
+
+ +
{t("dash.multiAgentGuidance")}
@@ -159,20 +145,7 @@ export function DashboardInjectionPanel({ apiBase, d }: { apiBase: string; d: Da
{syncResult && ( -
- +
+ {syncResult.nativeSubagentDefaultsWarning ? : } {t("dash.syncOk", { count: syncResult.added })} {syncResult.warning ? ` ${syncResult.warning}` : ""} + {syncResult.nativeSubagentDefaultsWarning ? ` ${syncResult.nativeSubagentDefaultsWarning}` : ""} {syncResult.staleAppServerHint ? ` ${t("dash.syncStaleHint")}` : ""}
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 8557f05461..2167c0ca95 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -60,6 +60,7 @@ export interface SyncResult { cacheSynced: boolean; message: string; warning?: string; + nativeSubagentDefaultsWarning?: string; staleAppServerHint?: string; projectConfigWarnings?: ProjectCodexConfigWarning[]; } @@ -151,7 +152,7 @@ export function sidecarBackendForModel(models: ModelInfo[], modelId: string): Si } let lastInputWasKeyboard = false; -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("keydown", () => { lastInputWasKeyboard = true; }, { capture: true, passive: true }); window.addEventListener("pointerdown", () => { lastInputWasKeyboard = false; }, { capture: true, passive: true }); } diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index b9ea608ac1..9ca46cb716 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -12,6 +12,7 @@ import { fetchDashboardModels, fetchProjectConfigDiagnostics, fetchStartupHealth, + normalizeInjectionSelection, type DashboardEpochRefs, } from "./dashboard-core-poll"; import { @@ -70,6 +71,7 @@ export function useDashboardData(apiBase: string) { const [injectionAvailable, setInjectionAvailable] = useState>([]); const [injectionSaving, setInjectionSaving] = useState(false); const [multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled] = useState(true); + const [syncCodexSubagentDefaults, setSyncCodexSubagentDefaults] = useState(false); const [effortCap, setEffortCap] = useState(""); const [subagentEffortCap, setSubagentEffortCap] = useState(""); const [effortCapSaving, setEffortCapSaving] = useState(false); @@ -200,6 +202,7 @@ export function useDashboardData(apiBase: string) { setMaModeResolved(data.maModeResolved); if (data.injection) { setMultiAgentGuidanceEnabled(data.injection.multiAgentGuidanceEnabled); + setSyncCodexSubagentDefaults(data.injection.syncCodexSubagentDefaults); setInjectionModel(data.injection.injectionModel); setInjectionEffort(data.injection.injectionEffort); setInjectionEfforts(data.injection.injectionEfforts); @@ -351,6 +354,41 @@ export function useDashboardData(apiBase: string) { finally { setMaBusy(false); } }; + const saveInjection = async (patch: { + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; + }) => { + if (injectionSaving) return; + setInjectionSaving(true); + try { + const res = await fetch(`${apiBase}/api/injection-model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) throw new Error("injection save failed"); + const getRes = await fetch(`${apiBase}/api/injection-model`); + const data = await requireJson<{ + multiAgentGuidanceEnabled?: boolean; + syncCodexSubagentDefaults?: boolean; + model?: string | null; + effort?: string | null; + efforts?: string[]; + available?: Array<{ provider: string; model: string; namespaced: string }>; + }>(getRes); + const normalized = normalizeInjectionSelection(data); + setMultiAgentGuidanceEnabled(normalized.multiAgentGuidanceEnabled); + setSyncCodexSubagentDefaults(normalized.syncCodexSubagentDefaults); + setInjectionModel(normalized.injectionModel); + setInjectionEffort(normalized.injectionEffort); + if (Array.isArray(data.efforts)) setInjectionEfforts(data.efforts); + if (Array.isArray(data.available)) setInjectionAvailable(data.available); + } catch { /* keep the last committed UI state */ } + finally { setInjectionSaving(false); } + }; + const toggleCodexAutoStart = async () => { if (!settings || settingsSaving) return; const next = !settings.codexAutoStart; @@ -484,8 +522,7 @@ export function useDashboardData(apiBase: string) { maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, effortCapHelpOpen, setEffortCapHelpOpen, shadowCallHelpOpen, setShadowCallHelpOpen, injectionModel, injectionEffort, injectionEfforts, injectionAvailable, injectionSaving, - setInjectionModel, setInjectionEffort, setInjectionSaving, - multiAgentGuidanceEnabled, setMultiAgentGuidanceEnabled, + multiAgentGuidanceEnabled, syncCodexSubagentDefaults, saveInjection, effortCap, subagentEffortCap, effortCapSaving, setEffortCap, setSubagentEffortCap, setEffortCapSaving, syncResult, syncError, projectConfigWarnings, updateOpen, updateChannel, setUpdateRestart, updateRestart, updateLoading, diff --git a/gui/src/stop-proxy.ts b/gui/src/stop-proxy.ts new file mode 100644 index 0000000000..ee98d3735f --- /dev/null +++ b/gui/src/stop-proxy.ts @@ -0,0 +1,66 @@ +export interface ProxyStopOutcome { + accepted: boolean; + message?: string; +} + +interface ProxyStopPayload { + success?: unknown; + message?: unknown; + error?: unknown; +} + +const DEFAULT_STOP_TIMEOUT_MS = 15_000; + +export interface ProxyStopOptions { + fetchFn?: typeof fetch; + timeoutMs?: number; + formatFailure?: (status: number) => string; +} + +function failureMessage( + payload: ProxyStopPayload | null, + status: number, + formatFailure: (status: number) => string, +): string { + if (typeof payload?.message === "string" && payload.message.trim()) return payload.message; + if (typeof payload?.error === "string" && payload.error.trim()) return payload.error; + return formatFailure(status); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +} + +/** + * A dropped connection is expected once shutdown starts. A received response, however, + * is authoritative: either a non-2xx status or `{ success: false }` means the UI must + * leave its pending state and surface the server's restore error. + */ +export async function requestProxyStop( + apiBase: string, + options: ProxyStopOptions = {}, +): Promise { + const { + fetchFn = fetch, + timeoutMs = DEFAULT_STOP_TIMEOUT_MS, + formatFailure = status => `Failed to stop proxy (HTTP ${status}).`, + } = options; + let response: Response; + try { + response = await fetchFn(`${apiBase}/api/stop`, { + method: "POST", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (isAbortError(error)) return { accepted: true }; + return { accepted: true }; + } + + const payload = await response.json().catch(() => null) as ProxyStopPayload | null; + if (!response.ok || payload?.success === false) { + return { accepted: false, message: failureMessage(payload, response.status, formatFailure) }; + } + return { accepted: true }; +} diff --git a/gui/tests/app-stop.test.ts b/gui/tests/app-stop.test.ts new file mode 100644 index 0000000000..24046b4556 --- /dev/null +++ b/gui/tests/app-stop.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { requestProxyStop } from "../src/stop-proxy"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("App proxy stop", () => { + test("releases the pending UI and exposes a non-2xx server message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({ + success: false, + message: "native Codex restore failed", + }, 500)) as typeof fetch, + formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, + }); + + expect(outcome).toEqual({ accepted: false, message: "native Codex restore failed" }); + }); + + test("rejects an HTTP 200 cleanup failure and exposes its server message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({ + success: false, + message: "native Codex cleanup failed", + })) as typeof fetch, + formatFailure: status => `Failed to stop proxy (HTTP ${status}).`, + }); + + expect(outcome).toEqual({ accepted: false, message: "native Codex cleanup failed" }); + }); + + test("treats a stop timeout like a dropped connection", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => { + throw new DOMException("The operation timed out.", "AbortError"); + }) as typeof fetch, + timeoutMs: 1, + }); + + expect(outcome).toEqual({ accepted: true }); + }); + + test("uses the localized fallback when the server omits a message", async () => { + const outcome = await requestProxyStop("", { + fetchFn: (async () => response({}, 503)) as typeof fetch, + formatFailure: status => `HTTP ${status} stop failed`, + }); + + expect(outcome).toEqual({ accepted: false, message: "HTTP 503 stop failed" }); + }); + + test("App clears stopping state and alerts for every rejected stop outcome", async () => { + const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); + const handleStopIdx = app.indexOf("const handleStop"); + const brandIdx = app.indexOf("const brand"); + expect(handleStopIdx).toBeGreaterThanOrEqual(0); + expect(brandIdx).toBeGreaterThan(handleStopIdx); + const handler = app.slice(handleStopIdx, brandIdx); + + expect(handler).toContain("await requestProxyStop(API_BASE"); + expect(handler).toContain("if (!outcome.accepted)"); + expect(handler).toContain("setStopping(false)"); + expect(handler).toContain("alert(outcome.message)"); + }); +}); diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 370808bc9c..4c6549bdc6 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { en } from "../src/i18n/en"; +import { normalizeInjectionSelection } from "../src/pages/dashboard-core-poll"; import { PROJECT_CONFIG_DIAGNOSTICS_POLL_MS } from "../src/startup-health-ui"; test("project-config diagnostics poll cadence is owned by the shared constant", () => { @@ -40,10 +42,39 @@ test("Dashboard workspace pane is a labelled section, not a nested main landmark expect(src).toMatch(/<(section)\b[^>]*dashboard-workspace-main/); }); -test("multi-agent guidance gates injection controls and Active badge on the enabled flag", async () => { +test("native Codex subagent defaults stay separate from OpenCodex guidance", async () => { + const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text(); const sections = await Bun.file(new URL("../src/pages/dashboard-overview-sections.tsx", import.meta.url)).text(); const head = await Bun.file(new URL("../src/pages/dashboard-overview-head.tsx", import.meta.url)).text(); - expect(sections).toContain("!multiAgentGuidanceEnabled"); - expect(sections).toContain("multiAgentGuidanceEnabled &&"); + expect(core).toContain("syncCodexSubagentDefaults: data.syncCodexSubagentDefaults === true"); + expect(sections).toContain("saveInjection({ syncCodexSubagentDefaults: !syncCodexSubagentDefaults })"); + expect(sections).toContain("disabled={injectionSaving || !injectionModel}"); + expect(sections).not.toContain("injectionSaving || !multiAgentGuidanceEnabled"); + expect(sections).not.toContain("dash.injectionActive"); + expect(en["dash.syncCodexSubagentDefaults"]).toBe("Use as native Codex subagent defaults"); + expect(en["dash.syncCodexSubagentDefaultsHint"]).toContain("Off by default"); + expect(en["dash.syncCodexSubagentDefaultsHint"]).toContain("existing user-owned [agents] defaults are preserved rather than overwritten"); + expect(en["dash.multiAgentGuidanceHint"]).not.toContain("proactive"); expect(head).toContain("models.v2Mode_"); }); + +test("injection writes consume the server's model-clear normalization", () => { + expect(normalizeInjectionSelection({ + multiAgentGuidanceEnabled: true, + syncCodexSubagentDefaults: false, + model: null, + effort: null, + })).toEqual({ + multiAgentGuidanceEnabled: true, + syncCodexSubagentDefaults: false, + injectionModel: "", + injectionEffort: "", + }); +}); + +test("Dashboard sync surfaces native subagent default warnings", async () => { + const sections = await Bun.file(new URL("../src/pages/dashboard-overview-sections.tsx", import.meta.url)).text(); + expect(sections).toContain("syncResult.nativeSubagentDefaultsWarning"); + expect(sections).toContain('"notice-warn"'); + expect(sections).toContain(""); +}); diff --git a/gui/tests/multi-agent-guidance.test.tsx b/gui/tests/multi-agent-guidance.test.tsx index 7f59ce7824..f3ee3dda38 100644 --- a/gui/tests/multi-agent-guidance.test.tsx +++ b/gui/tests/multi-agent-guidance.test.tsx @@ -1,14 +1,25 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act, useEffect, useState } from "react"; +import { act } from "react"; import type { Root } from "react-dom/client"; +import { en } from "../src/i18n/en"; +import { DashboardInjectionPanel } from "../src/pages/dashboard-overview-sections"; +import type { useDashboardData } from "../src/pages/use-dashboard-data"; const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; -let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; +let host: HTMLElement; +let root: Root | null = null; +let requests: unknown[] = []; + +type Dash = ReturnType; beforeEach(() => { - previousGlobals = Object.fromEntries(globals.map((key) => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousGlobals = Object.fromEntries( + globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; testWindow = new Window({ url: "http://localhost/" }); Object.defineProperties(globalThis, { document: { configurable: true, value: testWindow.document }, @@ -16,62 +27,107 @@ beforeEach(() => { navigator: { configurable: true, value: testWindow.navigator }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + requests = []; + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); }); -afterEach(() => { - testWindow.close(); - for (const key of globals) { - Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +afterEach(async () => { + try { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + } + } finally { + root = null; + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } } }); -/** Mirrors the Dashboard guidance control contract without mounting the full page. */ -function GuidanceControls({ - enabled, - model, -}: { - enabled: boolean; - model: string; -}) { - return ( -
- - - {enabled && model ? Active : null} -
- ); +function dash(overrides: Partial = {}): Dash { + return { + t: (key: keyof typeof en) => en[key], + injectionModel: "anthropic/claude-sonnet-5", + injectionEffort: "high", + injectionEfforts: ["low", "medium", "high"], + injectionAvailable: [{ provider: "anthropic", model: "claude-sonnet-5", namespaced: "anthropic/claude-sonnet-5" }], + injectionSaving: false, + multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: true, + saveInjection: async (patch) => { requests.push(patch); }, + ...overrides, + } as unknown as Dash; } -test("guidance-off keeps stored selection but hides Active and disables controls", async () => { +async function mount(d: Dash) { + // ReactDOM must observe the happy-dom globals installed by beforeEach. A static + // import binds to the prior document state and corrupts sibling suites. const { createRoot } = await import("react-dom/client"); - const host = document.createElement("div"); - document.body.append(host); - let root!: Root; - function Shell() { - const [enabled, setEnabled] = useState(false); - useEffect(() => {}, []); - return ( -
- - -
- ); - } await act(async () => { root = createRoot(host); - root.render(); + root.render(); }); - const model = host.querySelector('select[aria-label="model"]')!; - expect(model.disabled).toBe(true); - expect(model.value).toBe("claude"); - expect(host.textContent).not.toContain("Active"); - await act(async () => { host.querySelector("button")!.click(); }); +} + +test("renders independent guidance and native-default controls with editable selection", async () => { + await mount(dash()); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + const effort = host.querySelector('button[role="combobox"][aria-label="Reasoning effort"]')!; + const defaults = host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!; + const guidance = host.querySelector('button[aria-label="OpenCodex multi-agent guidance"]')!; + expect(model.disabled).toBe(false); - expect(host.textContent).toContain("Active"); - await act(async () => { root.unmount(); }); + expect(effort.disabled).toBe(false); + expect(defaults.disabled).toBe(false); + expect(defaults.getAttribute("aria-pressed")).toBe("true"); + expect(guidance.getAttribute("aria-pressed")).toBe("false"); + expect(host.textContent).not.toContain("Guidance active"); +}); + +test("requires a selected model before enabling native Codex subagent defaults", async () => { + await mount(dash({ + injectionModel: "", + injectionEffort: "", + injectionEfforts: [], + syncCodexSubagentDefaults: false, + })); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + const defaults = host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!; + expect(model.disabled).toBe(false); + expect(defaults.disabled).toBe(true); +}); + +test("sends the native-default opt-in independently from guidance", async () => { + await mount(dash({ + syncCodexSubagentDefaults: false, + })); + + await act(async () => { + host.querySelector('button[aria-label="Use as native Codex subagent defaults"]')!.click(); + await Promise.resolve(); + }); + + expect(requests).toEqual([{ syncCodexSubagentDefaults: true }]); +}); + +test("sends model clearing through the shared save path", async () => { + await mount(dash()); + + const model = host.querySelector('button[role="combobox"][aria-label="Sub-agent delegation"]')!; + await act(async () => { model.click(); }); + const none = Array.from(document.querySelectorAll('[role="option"]')) + .find(option => option.textContent === "None")!; + await act(async () => { + none.click(); + await Promise.resolve(); + }); + + expect(requests).toEqual([{ model: null, effort: "high" }]); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 196e52be87..5fc1187f80 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -227,8 +227,9 @@ async function handleStart(options: { block?: boolean } = {}) { let historyGuardian: ReturnType | undefined; let cleaned = false; + let cleanupSucceeded = true; const syncCleanup = () => { - if (cleaned) return; + if (cleaned) return cleanupSucceeded; cleaned = true; try { guardian.stop(); } catch { /* best-effort */ } try { historyGuardian?.stop(); } catch { /* best-effort */ } @@ -236,7 +237,16 @@ async function handleStart(options: { block?: boolean } = {}) { removePid(process.pid); removeRuntimePort(process.pid); if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { - try { restoreNativeCodex(); } catch { /* best-effort restore */ } + try { + const restored = restoreNativeCodex(); + if (!restored.success) { + cleanupSucceeded = false; + console.error(`⚠️ Native Codex restore failed during shutdown: ${restored.message}`); + } + } catch (error) { + cleanupSucceeded = false; + console.error(`⚠️ Native Codex restore failed during shutdown: ${error instanceof Error ? error.message : String(error)}`); + } } // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the // Grok fence is shared state we must not remove — that service keeps running and would be @@ -245,6 +255,7 @@ async function handleStart(options: { block?: boolean } = {}) { if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) { try { stripGrokConfig(); } catch { /* best-effort restore */ } } + return cleanupSucceeded; }; let shuttingDown = false; @@ -269,8 +280,8 @@ async function handleStart(options: { block?: boolean } = {}) { try { await drainAndShutdown(server, config.shutdownTimeoutMs ?? 5000); } finally { - syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") - process.exit(0); + const restored = syncCleanup(); // idempotent (cleaned-guard); also re-run by process.on("exit") + process.exit(restored ? 0 : 1); } })(); }; @@ -499,7 +510,11 @@ async function handleStop() { } if (!ownershipBlocked) { const r = restoreNativeCodex(); - console.log(`↩️ ${r.message}`); + if (r.success) console.log(`↩️ ${r.message}`); + else { + stopFailed = true; + console.error(`⚠️ ${r.message}`); + } } // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL). @@ -716,19 +731,40 @@ switch (command) { console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically."); process.exit(1); } - await syncModelsToCodex(live.port); + const synced = await syncModelsToCodex(live.port); + if (!synced.ok) { + process.exitCode = 1; + console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry."); + break; + } const target = collectOrcaCodexHomeDiagnostic(); console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`); break; } - const r = restoreNativeCodex(); - console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`); + let r: { success: boolean; message: string }; + try { + r = restoreNativeCodex(); + } catch (err) { + r = { success: false, message: err instanceof Error ? err.message : String(err) }; + } + if (r.success) console.log(`✅ ${r.message}`); + else { + console.error(`⚠️ ${r.message}`); + process.exitCode = 1; + } try { const g = stripGrokConfig(); if (g.changed) console.log(`✅ ${g.message}`); - else if (!g.ok) console.error(`⚠️ ${g.message}`); + else if (!g.ok) { + console.error(`⚠️ ${g.message}`); + process.exitCode = 1; + } } catch { /* best-effort */ } - console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + if (r.success) { + console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); + } else { + console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex."); + } break; } case "recover-history": @@ -767,7 +803,11 @@ switch (command) { break; } case "sync": { - await syncModelsToCodex((await findLiveProxy())?.port); + const synced = await syncModelsToCodex((await findLiveProxy())?.port); + if (!synced.ok) { + process.exitCode = 1; + console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); + } break; } case "v2": { diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 6fa7b5c5ee..c5692d1f49 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; -import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config"; +import { atomicWriteFile, loadConfig, subagentDefaultSyncEffective, websocketsEnabled } from "../config"; import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal"; import { restoreCodexCatalog } from "./catalog"; import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider"; @@ -15,6 +15,10 @@ import { } from "./injected-marker"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths"; import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; +import { + transformManagedSubagentDefaults, + type ManagedSubagentDefaults, +} from "./subagent-defaults"; import type { OcxConfig } from "../types"; // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them @@ -68,6 +72,16 @@ export interface InjectCodexOptions { catalogPath?: string | null; } +function configuredManagedSubagentDefaults( + config: Pick | undefined, +): ManagedSubagentDefaults | null { + if (!subagentDefaultSyncEffective(config ?? {})) return null; + return { + model: config!.injectionModel!.trim(), + ...(config!.injectionEffort?.trim() ? { reasoningEffort: config!.injectionEffort.trim() } : {}), + }; +} + /** * The `[model_providers.opencodex]` TABLE only. A table is position-independent in TOML, so it is * safe to append at EOF. The bare root key `model_provider = "opencodex"` is NOT included here — @@ -458,7 +472,13 @@ export function chooseCatalogPathForInjection(content: string, requested?: strin return existsSync(DEFAULT_CATALOG_PATH) ? DEFAULT_CATALOG_PATH : null; } -export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise<{ success: boolean; message: string }> { +export interface CodexInjectResult { + success: boolean; + message: string; + nativeSubagentDefaultsWarning?: string; +} + +export async function injectCodexConfig(port: number, config?: OcxConfig, options: InjectCodexOptions = {}): Promise { if (!existsSync(CODEX_CONFIG_PATH)) { return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` }; } @@ -469,8 +489,12 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option // A launcher may have journaled before the provider manager took ownership. Never let shutdown // replay that stale snapshot over externally managed config. removeJournal(); + const nativeSubagentDefaultsWarning = configuredManagedSubagentDefaults(config) + ? `Native Codex sub-agent defaults were not injected: external model_provider ${tomlString(activeProvider)} owns config.toml.` + : undefined; return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` + ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` + @@ -479,16 +503,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option }; } + // Marker-owned native defaults are OpenCodex residue, never part of the + // user's journal baseline. Clean them before either snapshotting or adding a + // root routing key: inserting that key ahead of a marker-owned first table + // would otherwise separate the table marker from its header. Ambiguous + // markers fail closed without writing config, profile, or journal state. + const nativeDefaultsBaseline = transformManagedSubagentDefaults(rawContent, null); + if (!nativeDefaultsBaseline.ok) { + return { + success: false, + message: `Codex config injection refused: existing OpenCodex-managed native sub-agent defaults are ambiguous: ${nativeDefaultsBaseline.error}. ` + + `No files were changed; inspect ${CODEX_CONFIG_PATH}.`, + }; + } + const baselineContent = nativeDefaultsBaseline.content; + // Classify and journal the same bytes: a native config is a valid original and // supersedes a stale snapshot (#477), while an injected one must never become // one — that is how opencodex routing would survive `ocx stop`. writeJournal({ currentStateIsNative: !hasInjectedCodexRouting(rawContent), - configContent: rawContent, + configContent: baselineContent, }); // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. const eol = dominantEol(rawContent); - let content = applyEol(rawContent, "\n"); + let content = applyEol(baselineContent, "\n"); // Idempotent clean-up of any prior injection: drop the provider table (marker-based) and every // stray/mis-nested model_provider line, so re-injecting can't duplicate keys or leave the buggy @@ -526,6 +565,31 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option keptUserBaseUrl = result.keptUserBaseUrl; } + const desiredSubagentDefaults = configuredManagedSubagentDefaults(config); + const routingOwnershipWarning = keptUserBaseUrl && desiredSubagentDefaults + ? "Native Codex sub-agent defaults were not injected: a user-owned root openai_base_url prevents OpenCodex from managing active Codex routing." + : undefined; + const managedDefaults = transformManagedSubagentDefaults( + content, + keptUserBaseUrl ? null : desiredSubagentDefaults, + ); + let nativeSubagentDefaultsWarning = routingOwnershipWarning; + let managedDefaultsMessage = routingOwnershipWarning ? ` ⚠️ ${routingOwnershipWarning}\n` : ""; + if (managedDefaults.ok) { + content = managedDefaults.content; + if (desiredSubagentDefaults && managedDefaults.conflicts.length > 0) { + const keys = managedDefaults.conflicts.map(conflict => `agents.${conflict.key}`).join(", "); + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults were not injected: user-owned ${keys} preserved.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + } else { + const action = desiredSubagentDefaults && !keptUserBaseUrl + ? "were not injected" + : "could not be safely removed"; + nativeSubagentDefaultsWarning = `Native Codex sub-agent defaults ${action}: ${managedDefaults.error}.`; + managedDefaultsMessage = ` ⚠️ ${nativeSubagentDefaultsWarning}\n`; + } + const profileContent = buildProfileFile(port, catalogPath, websocketsEnabled(config ?? {}), legacyMode, config?.hostname); content = applyEol(content, eol); atomicWriteFile(CODEX_CONFIG_PATH, content); @@ -560,9 +624,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option if (keptUserBaseUrl) { return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: `⚠️ Codex routing NOT injected: your config already sets a root openai_base_url, and opencodex never overwrites a user-owned override.\n` + catalogMessage + historyMessage + + managedDefaultsMessage + ` To route plain codex through the proxy, remove your openai_base_url line from ~/.codex/config.toml and rerun 'ocx start'.\n` + ` Reference config: ${CODEX_PROFILE_PATH}`, }; @@ -572,9 +638,11 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option : `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url).\n`; return { success: true, + ...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}), message: headline + catalogMessage + historyMessage + + managedDefaultsMessage + ` All models now route through opencodex proxy (like OpenRouter).\n` + ` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` + ` Custom models route to their configured providers.\n` + @@ -607,8 +675,17 @@ function removeOcxSection(content: string): string { return filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; } -/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ -export function stripOpencodexConfig(content: string): string { +interface StripOpencodexConfigResult { + content: string; + managedDefaultsError: string | null; +} + +/** + * Detailed form used by the on-disk restore path. A damaged ownership marker is + * ambiguous: keep the associated value, but return the transform error so the + * caller cannot report a complete restore. + */ +function stripOpencodexConfigResult(content: string): StripOpencodexConfigResult { let out = content; const hadRootOcxProvider = readRootTomlString(out, "model_provider") === "opencodex"; const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out); @@ -623,8 +700,18 @@ export function stripOpencodexConfig(content: string): string { // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves // them — strip on both the legacy re-tag form and the Design B injected-base-url form. if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); + const managedDefaults = transformManagedSubagentDefaults(out, null); + if (managedDefaults.ok) out = managedDefaults.content; out = stripOpencodexCatalogPath(out); - return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; + return { + content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", + managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null, + }; +} + +/** Pure transform: strip the opencodex provider block + `model_provider = "opencodex"` lines. */ +export function stripOpencodexConfig(content: string): string { + return stripOpencodexConfigResult(content).content; } function hasOpencodexRouting(content: string): boolean { @@ -635,7 +722,11 @@ function hasOpencodexRouting(content: string): boolean { export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): { success: boolean; message: string } { if (!existsSync(CODEX_CONFIG_PATH)) { - return { success: false, message: "Codex config not found." }; + if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + return { + success: true, + message: `Codex config not found; no native restore was needed${options.preserveProfile ? "." : ", and the opencodex profile was removed if present."}`, + }; } const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); // Same EOL boundary as inject: strip in LF space, write back in the file's own ending. @@ -643,16 +734,25 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): const eol = dominantEol(rawContent); const content = applyEol(rawContent, "\n"); const had = hasOpencodexRouting(content); - const stripped = stripOpencodexConfig(content); - if (had || stripped !== content) { - atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped, eol)); + const stripped = stripOpencodexConfigResult(content); + if (had || stripped.content !== content) { + atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol)); } if (!options.preserveProfile && existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH); + const removedMessage = had + ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` + : "opencodex not present in Codex config."; + if (stripped.managedDefaultsError) { + const routingMessage = had ? removedMessage : "No opencodex routing was present in Codex config."; + return { + success: false, + message: `${routingMessage} Native Codex sub-agent defaults could not be safely removed: ${stripped.managedDefaultsError}. ` + + "The ambiguous marker and adjacent value were preserved; inspect $CODEX_HOME/config.toml before using native Codex.", + }; + } return { success: true, - message: had - ? `Removed opencodex routing from Codex config${options.preserveProfile ? "." : " + profile."}` - : "opencodex not present in Codex config.", + message: removedMessage, }; } diff --git a/src/codex/subagent-defaults.ts b/src/codex/subagent-defaults.ts new file mode 100644 index 0000000000..060ee0cd36 --- /dev/null +++ b/src/codex/subagent-defaults.ts @@ -0,0 +1,550 @@ +/** + * Pure, ownership-aware edits for Codex's native `[agents]` defaults. + * + * This intentionally does not parse and re-serialize the whole TOML document: + * callers can write the returned content atomically while comments, ordering, + * unknown keys, and line endings remain untouched. Every value and table that + * this transform may later remove has its own immediately preceding marker. + */ + +export const MANAGED_SUBAGENT_DEFAULT_MARKER = "# Managed by opencodex: native subagent default"; +export const MANAGED_AGENTS_TABLE_MARKER = "# Managed by opencodex: native subagent defaults table"; + +export type ManagedSubagentDefaultKey = + | "default_subagent_model" + | "default_subagent_reasoning_effort"; + +export interface ManagedSubagentDefaults { + model: string; + reasoningEffort?: string; +} + +export interface ManagedSubagentDefaultsConflict { + key: ManagedSubagentDefaultKey; + line: number; + reason: "user-owned"; +} + +export type ManagedSubagentDefaultsTransformResult = + | { + ok: true; + changed: boolean; + content: string; + conflicts: ManagedSubagentDefaultsConflict[]; + } + | { + ok: false; + changed: false; + content: string; + conflicts: []; + error: string; + }; + +interface SourceLine { + text: string; + eol: "\r\n" | "\n" | ""; + /** False when this physical line began inside a TOML multiline string. */ + structural: boolean; +} + +interface TargetDefinition { + key: ManagedSubagentDefaultKey; + index: number; + owned: boolean; +} + +interface TomlShape { + agentsHeader: number | null; + agentsEnd: number | null; + tableOwned: boolean; + definitions: Map; +} + +const TARGET_KEYS: readonly ManagedSubagentDefaultKey[] = [ + "default_subagent_model", + "default_subagent_reasoning_effort", +]; + +function splitSourceLines(content: string): SourceLine[] { + const lines: SourceLine[] = []; + let offset = 0; + while (offset < content.length) { + const lf = content.indexOf("\n", offset); + if (lf === -1) { + lines.push({ text: content.slice(offset), eol: "", structural: true }); + break; + } + const crlf = lf > offset && content[lf - 1] === "\r"; + lines.push({ + text: content.slice(offset, crlf ? lf - 1 : lf), + eol: crlf ? "\r\n" : "\n", + structural: true, + }); + offset = lf + 1; + } + markStructuralLines(lines); + return lines; +} + +type MultilineStringKind = "basic" | "literal" | null; + +/** + * TOML table-looking text inside a multiline string is data, not syntax. Keep + * a deliberately small lexical state machine so the format-preserving editor + * never treats those physical lines as headers, keys, or ownership markers. + */ +function markStructuralLines(lines: SourceLine[]): void { + let multiline: MultilineStringKind = null; + let squareDepth = 0; + let curlyDepth = 0; + + for (const line of lines) { + line.structural = multiline === null && squareDepth === 0 && curlyDepth === 0; + let single: "basic" | "literal" | null = null; + + for (let index = 0; index < line.text.length;) { + if (multiline === "basic") { + if (line.text.startsWith('"""', index)) { + multiline = null; + index += 3; + } else if (line.text[index] === "\\") { + index += 2; + } else { + index += 1; + } + continue; + } + if (multiline === "literal") { + if (line.text.startsWith("'''", index)) { + multiline = null; + index += 3; + } else { + index += 1; + } + continue; + } + if (single === "basic") { + if (line.text[index] === "\\") index += 2; + else if (line.text[index] === '"') { + single = null; + index += 1; + } else index += 1; + continue; + } + if (single === "literal") { + if (line.text[index] === "'") single = null; + index += 1; + continue; + } + + if (line.text[index] === "#") break; + if (line.text.startsWith('"""', index)) { + multiline = "basic"; + index += 3; + } else if (line.text.startsWith("'''", index)) { + multiline = "literal"; + index += 3; + } else if (line.text[index] === '"') { + single = "basic"; + index += 1; + } else if (line.text[index] === "'") { + single = "literal"; + index += 1; + } else if (line.text[index] === "[") { + squareDepth += 1; + index += 1; + } else if (line.text[index] === "]") { + squareDepth = Math.max(0, squareDepth - 1); + index += 1; + } else if (line.text[index] === "{") { + curlyDepth += 1; + index += 1; + } else if (line.text[index] === "}") { + curlyDepth = Math.max(0, curlyDepth - 1); + index += 1; + } else { + index += 1; + } + } + } +} + +function joinSourceLines(lines: readonly SourceLine[]): string { + return lines.map(line => `${line.text}${line.eol}`).join(""); +} + +function dominantEol(lines: readonly SourceLine[]): "\r\n" | "\n" { + let crlf = 0; + let lf = 0; + for (const line of lines) { + if (line.eol === "\r\n") crlf += 1; + else if (line.eol === "\n") lf += 1; + } + return crlf > 0 && crlf >= lf ? "\r\n" : "\n"; +} + +/** Decode a TOML basic-string key body, including Unicode escapes. */ +function decodeTomlBasicKey(body: string): string { + return body.replace( + /\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g, + (whole, escape: string) => { + if (escape[0] === "x") return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (escape[0] === "u") return String.fromCharCode(Number.parseInt(escape.slice(1), 16)); + if (escape[0] === "U") { + const codePoint = Number.parseInt(escape.slice(1), 16); + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : whole; + } + switch (escape) { + case "b": return "\b"; + case "t": return "\t"; + case "n": return "\n"; + case "f": return "\f"; + case "r": return "\r"; + case '"': return '"'; + case "\\": return "\\"; + default: return whole; + } + }, + ); +} + +function canonicalKeySegment(raw: string): string { + if (raw.startsWith('"')) return decodeTomlBasicKey(raw.slice(1, -1)); + if (raw.startsWith("'")) return raw.slice(1, -1); + return raw; +} + +const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; +const EXACT_TABLE_HEADER = new RegExp(`^\\s*\\[\\s*(${KEY_SEGMENT})\\s*\\]\\s*(?:#.*)?$`); +const ARRAY_TABLE_HEADER = new RegExp(`^\\s*\\[\\[\\s*(${KEY_SEGMENT})\\s*\\]\\]\\s*(?:#.*)?$`); +const DOTTED_TABLE_HEADER = new RegExp(`^\\s*\\[\\[?\\s*(${KEY_SEGMENT})\\s*\\.\\s*(${KEY_SEGMENT})(?:\\s*\\.|\\s*\\]\\]?)`); +const KEY_ASSIGNMENT = new RegExp(`^\\s*(${KEY_SEGMENT})\\s*=`); +const DOTTED_ASSIGNMENT = new RegExp(`^\\s*(${KEY_SEGMENT})\\s*\\.\\s*(${KEY_SEGMENT})(?:\\s*\\.|\\s*=)`); +const ANY_TABLE_HEADER = /^\s*\[{1,2}/; + +function exactAgentsHeader(line: SourceLine | undefined): boolean { + if (!line?.structural) return false; + const match = line.text.match(EXACT_TABLE_HEADER); + return match !== null && canonicalKeySegment(match[1]!) === "agents"; +} + +function arrayAgentsHeader(line: SourceLine): boolean { + if (!line.structural) return false; + const match = line.text.match(ARRAY_TABLE_HEADER); + return match !== null && canonicalKeySegment(match[1]!) === "agents"; +} + +function dottedAgentsHeader(line: SourceLine): { second: string } | null { + if (!line.structural) return null; + const match = line.text.match(DOTTED_TABLE_HEADER); + if (!match || canonicalKeySegment(match[1]!) !== "agents") return null; + return { second: canonicalKeySegment(match[2]!) }; +} + +function isAnyTableHeader(line: SourceLine): boolean { + return line.structural && ANY_TABLE_HEADER.test(line.text); +} + +function assignmentKeyAt(line: SourceLine): string | null { + if (!line.structural) return null; + const match = line.text.match(KEY_ASSIGNMENT); + return match ? canonicalKeySegment(match[1]!) : null; +} + +function markerLine(line: SourceLine | undefined, marker: string): boolean { + return line?.structural === true && line.text.trim() === marker; +} + +function targetKeyAt(line: SourceLine | undefined): ManagedSubagentDefaultKey | null { + if (!line) return null; + const key = assignmentKeyAt(line); + return TARGET_KEYS.includes(key as ManagedSubagentDefaultKey) + ? key as ManagedSubagentDefaultKey + : null; +} + +function dottedAssignmentAt(line: SourceLine): { first: string; second: string } | null { + if (!line.structural) return null; + const match = line.text.match(DOTTED_ASSIGNMENT); + if (!match) return null; + return { + first: canonicalKeySegment(match[1]!), + second: canonicalKeySegment(match[2]!), + }; +} + +function dottedTargetAt(line: SourceLine): ManagedSubagentDefaultKey | null { + const dotted = dottedAssignmentAt(line); + if (!dotted) return null; + return TARGET_KEYS.includes(dotted.first as ManagedSubagentDefaultKey) + ? dotted.first as ManagedSubagentDefaultKey + : null; +} + +function analyzeToml(lines: readonly SourceLine[]): { shape: TomlShape } | { error: string } { + const exactHeaders: number[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + if (exactAgentsHeader(line)) exactHeaders.push(index); + else if (arrayAgentsHeader(line)) { + return { error: "array [[agents]] tables are not supported for managed subagent defaults" }; + } else if (TARGET_KEYS.includes(dottedAgentsHeader(line)?.second as ManagedSubagentDefaultKey)) { + return { error: "agents default keys cannot be represented as nested tables" }; + } + } + if (exactHeaders.length > 1) { + return { error: "duplicate [agents] tables cannot be updated safely" }; + } + + const firstTable = lines.findIndex(isAnyTableHeader); + const rootEnd = firstTable === -1 ? lines.length : firstTable; + for (let index = 0; index < rootEnd; index += 1) { + const line = lines[index]!; + const dotted = dottedAssignmentAt(line); + if (dotted?.first === "agents" + && TARGET_KEYS.includes(dotted.second as ManagedSubagentDefaultKey)) { + return { error: "dotted agents default keys are not supported for managed subagent defaults" }; + } + if (assignmentKeyAt(line) === "agents") { + return { error: "inline agents definitions cannot be updated safely" }; + } + } + + const agentsHeader = exactHeaders[0] ?? null; + let agentsEnd: number | null = null; + const definitions = new Map(); + if (agentsHeader !== null) { + agentsEnd = lines.length; + for (let index = agentsHeader + 1; index < lines.length; index += 1) { + if (isAnyTableHeader(lines[index]!)) { + agentsEnd = index; + break; + } + const dotted = dottedTargetAt(lines[index]!); + if (dotted) { + return { error: `dotted agents.${dotted} fields are not supported` }; + } + const key = targetKeyAt(lines[index]); + if (!key) continue; + if (definitions.has(key)) { + return { error: `duplicate agents.${key} definitions cannot be updated safely` }; + } + definitions.set(key, { + key, + index, + owned: markerLine(lines[index - 1], MANAGED_SUBAGENT_DEFAULT_MARKER), + }); + } + } + + for (let index = 0; index < lines.length; index += 1) { + if (markerLine(lines[index], MANAGED_SUBAGENT_DEFAULT_MARKER)) { + const nextKey = targetKeyAt(lines[index + 1]); + const insideAgents = agentsHeader !== null + && agentsEnd !== null + && index > agentsHeader + && index + 1 < agentsEnd; + if (!nextKey || !insideAgents) { + return { error: "orphaned managed subagent default marker cannot be updated safely" }; + } + } + if (markerLine(lines[index], MANAGED_AGENTS_TABLE_MARKER)) { + if (index + 1 !== agentsHeader) { + return { error: "orphaned managed agents table marker cannot be updated safely" }; + } + } + } + + return { + shape: { + agentsHeader, + agentsEnd, + tableOwned: agentsHeader !== null && markerLine(lines[agentsHeader - 1], MANAGED_AGENTS_TABLE_MARKER), + definitions, + }, + }; +} + +function quotedTomlString(value: string): string { + // JSON basic strings are valid TOML basic strings and cover quotes, + // backslashes, control characters, and newlines. JSON leaves DEL literal, + // while TOML requires it escaped. + return JSON.stringify(value).replace(/\u007f/g, "\\u007F"); +} + +function containsLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function replaceManagedString(line: string, key: ManagedSubagentDefaultKey, value: string): string | null { + const stringValue = `(?:"(?:\\\\.|[^"\\\\])*"|'[^']*')`; + const match = line.match(new RegExp(`^(\\s*)(${KEY_SEGMENT})(\\s*=\\s*)(${stringValue})(\\s*(?:#.*)?)$`)); + if (!match || canonicalKeySegment(match[2]!) !== key) return null; + const quoted = quotedTomlString(value); + if (match[4] === quoted) return line; + return `${match[1]}${match[2]}${match[3]}${quoted}${match[5]}`; +} + +function insertedLines( + key: ManagedSubagentDefaultKey, + value: string, + eol: "\r\n" | "\n", +): SourceLine[] { + return [ + { text: MANAGED_SUBAGENT_DEFAULT_MARKER, eol, structural: true }, + { text: `${key} = ${quotedTomlString(value)}`, eol, structural: true }, + ]; +} + +function invalidInput(content: string, error: string): ManagedSubagentDefaultsTransformResult { + return { ok: false, changed: false, content, conflicts: [], error }; +} + +/** + * Add/update opencodex-owned native subagent defaults, or remove them with + * `defaults = null`. Unmarked target keys are user-owned: they are retained and + * returned as conflicts rather than overwritten. Ambiguous TOML is rejected + * byte-for-byte so callers never have to guess what was changed. + */ +export function transformManagedSubagentDefaults( + content: string, + defaults: ManagedSubagentDefaults | null, +): ManagedSubagentDefaultsTransformResult { + if (defaults !== null) { + if (typeof defaults.model !== "string" || defaults.model.length === 0) { + return invalidInput(content, "managed subagent model must be a non-empty string"); + } + if (containsLoneSurrogate(defaults.model)) { + return invalidInput(content, "managed subagent model must contain valid Unicode scalar values"); + } + if (defaults.reasoningEffort !== undefined + && (typeof defaults.reasoningEffort !== "string" || defaults.reasoningEffort.length === 0)) { + return invalidInput(content, "managed subagent reasoning effort must be a non-empty string when supplied"); + } + if (defaults.reasoningEffort !== undefined && containsLoneSurrogate(defaults.reasoningEffort)) { + return invalidInput(content, "managed subagent reasoning effort must contain valid Unicode scalar values"); + } + } + + const lines = splitSourceLines(content); + if (defaults === null && !lines.some(line => + markerLine(line, MANAGED_SUBAGENT_DEFAULT_MARKER) + || markerLine(line, MANAGED_AGENTS_TABLE_MARKER))) { + return { ok: true, changed: false, content, conflicts: [] }; + } + const analysis = analyzeToml(lines); + if ("error" in analysis) return invalidInput(content, analysis.error); + const { shape } = analysis; + const eol = dominantEol(lines); + const desired = new Map(); + if (defaults !== null) { + desired.set("default_subagent_model", defaults.model); + if (defaults.reasoningEffort !== undefined) { + desired.set("default_subagent_reasoning_effort", defaults.reasoningEffort); + } + } + + const conflicts: ManagedSubagentDefaultsConflict[] = []; + const missing: Array<[ManagedSubagentDefaultKey, string]> = []; + const removals: number[] = []; + + if (defaults !== null) { + for (const key of TARGET_KEYS) { + const definition = shape.definitions.get(key); + if (definition && !definition.owned) { + conflicts.push({ key, line: definition.index + 1, reason: "user-owned" }); + } + } + // Model and effort are one effective native-default pair. A partial write + // could apply a newly managed effort to a user's model (or vice versa), so + // any desired-key ownership conflict makes the whole enable/update a no-op. + if (conflicts.length > 0) { + return { ok: true, changed: false, content, conflicts }; + } + } + + for (const key of TARGET_KEYS) { + const definition = shape.definitions.get(key); + const value = desired.get(key); + if (!definition) { + if (value !== undefined) missing.push([key, value]); + continue; + } + if (!definition.owned) { + continue; + } + if (replaceManagedString(lines[definition.index]!.text, key, value ?? "__opencodex_validation__") === null) { + return invalidInput(content, `managed agents.${key} is not a supported single-line TOML string`); + } + if (value === undefined) { + removals.push(definition.index - 1, definition.index); + continue; + } + const replacement = replaceManagedString(lines[definition.index]!.text, key, value); + if (replacement === null) { + return invalidInput(content, `managed agents.${key} is not a supported single-line TOML string`); + } + lines[definition.index]!.text = replacement; + } + + removals.sort((a, b) => b - a); + for (const index of removals) lines.splice(index, 1); + + if (missing.length > 0) { + const pairs = missing.flatMap(([key, value]) => insertedLines(key, value, eol)); + const currentHeader = lines.findIndex(exactAgentsHeader); + if (currentHeader !== -1) { + if (lines[currentHeader]!.eol === "") lines[currentHeader]!.eol = eol; + lines.splice(currentHeader + 1, 0, ...pairs); + } else { + const table = [ + { text: MANAGED_AGENTS_TABLE_MARKER, eol, structural: true }, + { text: "[agents]", eol, structural: true }, + ...pairs, + ]; + const nestedHeader = lines.findIndex(line => dottedAgentsHeader(line) !== null); + if (nestedHeader !== -1) { + lines.splice(nestedHeader, 0, ...table); + } else { + if (lines.length > 0 && lines[lines.length - 1]!.eol === "") lines[lines.length - 1]!.eol = eol; + lines.push(...table); + } + } + } + + // A table created by this transform is removable only after every body line + // is blank. Comments or unknown keys make it user-extended and preserve it. + if (shape.tableOwned) { + const currentHeader = lines.findIndex(exactAgentsHeader); + if (currentHeader !== -1 && markerLine(lines[currentHeader - 1], MANAGED_AGENTS_TABLE_MARKER)) { + let currentEnd = lines.length; + for (let index = currentHeader + 1; index < lines.length; index += 1) { + if (isAnyTableHeader(lines[index]!)) { + currentEnd = index; + break; + } + } + if (lines.slice(currentHeader + 1, currentEnd).every(line => line.text.trim() === "")) { + lines.splice(currentHeader - 1, currentEnd - currentHeader + 1); + } else if (defaults === null) { + // The table outlived the defaults because a user added content. Drop + // only our ownership claim; the surviving table and extensions are now + // wholly user-owned. + lines.splice(currentHeader - 1, 1); + } + } + } + + const output = joinSourceLines(lines); + return { ok: true, changed: output !== content, content: output, conflicts }; +} diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 27040f92dc..219d3f6a2d 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -16,6 +16,7 @@ export interface CodexSyncResult { message: string; warning?: string; comboOmissions?: ComboCatalogOmission[]; + nativeSubagentDefaultsWarning?: string; projectConfigWarnings?: ProjectCodexConfigWarning[]; projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[]; } @@ -65,6 +66,7 @@ export async function syncModelsToCodex( catalogWritten: false, cacheSynced: false, message: result.message, + ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), }; } @@ -119,6 +121,7 @@ export async function syncModelsToCodex( message: result.message, ...(warning ? { warning } : {}), ...(comboOmissions.length > 0 ? { comboOmissions } : {}), + ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), ...(projectConfigWarnings.length > 0 ? { projectConfigWarnings, projectConfigGrouped: groupProjectCodexConfigWarningsByPath(projectConfigWarnings), diff --git a/src/config.ts b/src/config.ts index c056e19542..68adef2962 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,7 @@ import { } from "./types"; import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; -import { modelRecordValue } from "./reasoning-effort"; +import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; let _atomicSeq = 0; @@ -515,6 +515,13 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + // These selections pre-date schema validation and used to pass through as + // unknown fields. Invalid hand edits must disable only the optional + // delegation/native-default feature, not reject the whole config and hide + // otherwise valid providers, accounts, or the configured listen port. + injectionModel: z.string().optional().catch(undefined), + injectionEffort: z.string().optional().catch(undefined), + syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), @@ -773,6 +780,64 @@ function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void } } +type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; + +function rawConfigRecord(rawParsed: unknown): Record | null { + return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed) + ? rawParsed as Record + : null; +} + +function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const malformed: NativeSubagentPersistedField[] = []; + if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") { + malformed.push("injectionModel"); + } + if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") { + malformed.push("injectionEffort"); + } + if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") { + malformed.push("syncCodexSubagentDefaults"); + } + return malformed; +} + +function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string { + const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string"; + return `${field} ignored: expected ${expected}`; +} + +function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null { + if (config.syncCodexSubagentDefaults !== true) return null; + const malformed = malformedNativeSubagentFields(rawParsed); + if (malformed.includes("injectionModel")) return "injectionModel must be a string"; + if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required"; + if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted"; + if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) { + return "injectionEffort must be a supported Codex reasoning effort"; + } + return null; +} + +function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig { + if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config; + const normalized = { ...config }; + delete normalized.syncCodexSubagentDefaults; + return normalized; +} + +function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void { + for (const field of malformedNativeSubagentFields(rawParsed)) { + console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`); + } + const reason = nativeSubagentSyncDisabledReason(config, rawParsed); + if (reason) { + console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`); + } +} + export function loadConfig(): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); @@ -787,8 +852,10 @@ export function loadConfig(): OcxConfig { const parsed = JSON.parse(raw); const result = configSchema.safeParse(parsed); if (result.success) { - warnDegradedStreamMode(parsed, result.data as OcxConfig); - return result.data as OcxConfig; + const config = result.data as OcxConfig; + warnDegradedStreamMode(parsed, config); + warnDegradedNativeSubagentConfig(parsed, config); + return normalizeNativeSubagentSync(config, parsed); } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing @@ -802,7 +869,9 @@ export function loadConfig(): OcxConfig { const retryResult = configSchema.safeParse(merged); if (retryResult.success) { warnConfigRepaired(configPath, result.error); - return retryResult.data as OcxConfig; + const config = retryResult.data as OcxConfig; + warnDegradedNativeSubagentConfig(parsed, config); + return normalizeNativeSubagentSync(config, parsed); } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); @@ -832,16 +901,31 @@ function configPlaceholderWarnings(config: OcxConfig): string[] { return warnings; } -function validFileConfigDiagnostics(config: OcxConfig): ConfigDiagnostics { - const warnings = configPlaceholderWarnings(config); +function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics { + // An unsafe hand-edited opt-in is disabled in memory instead of rejecting + // the entire config, which would hide unrelated providers/accounts. The next + // ordinary save persists the normalized absence. + const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); + const normalized = normalizeNativeSubagentSync(config, rawParsed); + const warnings = configPlaceholderWarnings(normalized); + warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); + if (syncDisabledReason) { + warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); + } return { - config, + config: normalized, source: "file", error: null, ...(warnings.length > 0 ? { warnings } : {}), }; } +export function subagentDefaultSyncEffective( + config: Pick, +): boolean { + return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); +} + function mergeConfigDefaults(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const defaults = getDefaultConfig(); @@ -878,12 +962,12 @@ export function readConfigDiagnostics(): ConfigDiagnostics { const parsed = JSON.parse(raw); const result = configSchema.safeParse(parsed); if (result.success) { - return validFileConfigDiagnostics(result.data as OcxConfig); + return validFileConfigDiagnostics(result.data as OcxConfig, parsed); } const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed)); if (retryResult.success) { - return validFileConfigDiagnostics(retryResult.data as OcxConfig); + return validFileConfigDiagnostics(retryResult.data as OcxConfig, parsed); } return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) }; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 4108a3bb7a..96bb39c994 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -11,6 +11,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, saveConfigPreservingClaudeCode, + subagentDefaultSyncEffective, } from "../../config"; import { clearLoginState, @@ -190,6 +191,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ))); return jsonResponse({ multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), + syncCodexSubagentDefaults: subagentDefaultSyncEffective(config), model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, @@ -207,6 +209,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const body = parsedBody as { multiAgentGuidanceEnabled?: unknown; + syncCodexSubagentDefaults?: unknown; model?: unknown; effort?: unknown; prompt?: unknown; @@ -214,6 +217,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { isCodexReasoningEffort } = await import("../../reasoning-effort"); let nextEnabled = config.multiAgentGuidanceEnabled; + // Start from the effective state reported by GET. A stale hand-edited + // `true` without a model must not spring back on during a model-only PUT. + let nextSyncCodexSubagentDefaults = subagentDefaultSyncEffective(config); let nextModel = config.injectionModel; let nextEffort = config.injectionEffort; let nextPrompt = config.injectionPrompt; @@ -224,10 +230,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } nextEnabled = body.multiAgentGuidanceEnabled; } + if ("syncCodexSubagentDefaults" in body) { + if (typeof body.syncCodexSubagentDefaults !== "boolean") { + return jsonResponse({ error: "syncCodexSubagentDefaults must be a boolean" }, 400); + } + nextSyncCodexSubagentDefaults = body.syncCodexSubagentDefaults; + } if ("model" in body) { if (body.model === null || body.model === "") nextModel = undefined; - else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model; - else return jsonResponse({ error: "model must be a non-empty string or null" }, 400); + else if (typeof body.model === "string" && body.model.trim().length > 0) nextModel = body.model; + else return jsonResponse({ error: "model must be a nonblank string or null" }, 400); } if ("effort" in body) { if (body.effort === null || body.effort === "") nextEffort = undefined; @@ -242,10 +254,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise else if (body.prompt === null || body.prompt === "") nextPrompt = undefined; else return jsonResponse({ error: "prompt must be a string or null" }, 400); } - // Clearing the model always clears the effort (it is meaningless alone). - if (!nextModel) nextEffort = undefined; + // Clearing the model always clears model-dependent settings before sync/effort gates. + if (!nextModel) { + nextEffort = undefined; + nextSyncCodexSubagentDefaults = false; + } + if (body.syncCodexSubagentDefaults === true && !nextModel?.trim()) { + return jsonResponse({ error: "syncCodexSubagentDefaults requires an injection model" }, 400); + } + if (nextSyncCodexSubagentDefaults && nextEffort !== undefined && !isCodexReasoningEffort(nextEffort)) { + return jsonResponse({ error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort" }, 400); + } config.multiAgentGuidanceEnabled = nextEnabled; + if (nextSyncCodexSubagentDefaults) config.syncCodexSubagentDefaults = true; + else delete config.syncCodexSubagentDefaults; if (nextModel) config.injectionModel = nextModel; else delete config.injectionModel; if (nextEffort) config.injectionEffort = nextEffort; @@ -257,6 +280,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ ok: true, multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config), + syncCodexSubagentDefaults: subagentDefaultSyncEffective(config), model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index db829c13e2..c7a119771e 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -228,6 +228,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise`. | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | Expose API-key provider presets for setup and dashboard flows. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. | -| Subagents | Read/write the featured `subagentModels` list capped at five ids. | +| Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), and the logical maximum thread count. Selecting `v2` enables the native flag and migrates `[agents] max_threads` to the v2 key; selecting `v1` disables it and migrates the same value back. `default` leaves the native flag unchanged. PUT accepts `enabled`, `multiAgentMode`, and/or the compatibility-named `maxConcurrentThreadsPerSession`; contradictory mode/flag pairs are rejected before writes. Every transition is rollback-safe and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index 2096611797..42a2fc2f6b 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; const cliSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); const helpSource = readFileSync(join(import.meta.dir, "..", "src", "cli", "help.ts"), "utf8"); +const repoRoot = join(import.meta.dir, ".."); describe("ocx restore back", () => { test("restore/eject accept `back` to re-point codex at the RUNNING proxy only", () => { @@ -15,9 +18,54 @@ describe("ocx restore back", () => { expect(restoreCase).toContain("await findLiveProxy()"); expect(restoreCase).toContain("await syncModelsToCodex(live.port)"); expect(restoreCase.indexOf("findLiveProxy()")).toBeLessThan(restoreCase.indexOf("syncModelsToCodex(live.port)")); + expect(restoreCase).toContain("if (!synced.ok)"); + expect(restoreCase.indexOf("if (!synced.ok)")).toBeLessThan(restoreCase.indexOf("target.effectiveCodexHome")); expect(restoreCase).toContain("target.effectiveCodexHome"); - // The forward switch (plain `ocx restore`) is unchanged. + // The forward switch reports incomplete marker cleanup instead of claiming native success. expect(restoreCase).toContain("restoreNativeCodex()"); + expect(restoreCase).toContain("process.exitCode = 1"); + expect(restoreCase).toContain("was not fully restored"); + }); + + test("sync propagates injection refusal as a nonzero CLI result", () => { + const syncCase = cliSource.slice(cliSource.indexOf('case "sync":'), cliSource.indexOf('case "v2":')); + + expect(syncCase).toContain("await syncModelsToCodex"); + expect(syncCase).toContain("if (!synced.ok)"); + expect(syncCase).toContain("process.exitCode = 1"); + }); + + test("sync exits nonzero when managed-default cleanup is ambiguous", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-codex-")); + const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-sync-home-")); + try { + writeFileSync(join(codexHome, "config.toml"), [ + "# Managed by opencodex: native subagent defaults table", + "[agents]", + "# Managed by opencodex: native subagent default", + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + providers: {}, + defaultProvider: "openai", + checkForUpdates: false, + }), "utf8"); + + const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("Codex config injection refused"); + expect(result.stderr).toContain("Codex sync did not complete"); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + rmSync(ocxHome, { recursive: true, force: true }); + } }); test("help documents both directions of the switch", () => { diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 51a2da3478..d66de56361 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Database } from "bun:sqlite"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -96,6 +100,124 @@ describe("injectCodexConfig integration (Design B)", () => { expect(second).toBe(first); }); + test("opt-in injects native subagent defaults, removes them when disabled, and restores the native config", () => { + const original = [ + 'model = "gpt-5.5"', + "", + "[notice]", + "hide = true", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + const enabled = JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + }); + + expect(runInject(codexHome, ocxHome, enabled).status).toBe(0); + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + const profile = readFileSync(join(codexHome, "opencodex.config.toml"), "utf8"); + expect(injected).toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).toContain('default_subagent_model = "gpt-5.6-sol"'); + expect(injected).toContain('default_subagent_reasoning_effort = "high"'); + expect(injected).toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(profile).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(profile).not.toContain("default_subagent_model"); + + expect(runInject(codexHome, ocxHome, "{}").status).toBe(0); + const disabled = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(disabled).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(disabled).not.toContain("default_subagent_model"); + expect(disabled).not.toContain("default_subagent_reasoning_effort"); + expect(disabled).toContain("[notice]\nhide = true"); + + expect(runInject(codexHome, ocxHome, enabled).status).toBe(0); + expect(runRestore(codexHome, ocxHome).status).toBe(0); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + }); + + test("opt-in preserves a user-owned native default pair and reports the conflict", () => { + const original = [ + 'model = "gpt-5.5"', + "", + "[agents]", + 'default_subagent_model = "user/model" # owned by user', + 'default_subagent_reasoning_effort = "medium"', + "max_threads = 6", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const result = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).message).toContain("user-owned agents.default_subagent_model"); + + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(injected).toContain('default_subagent_model = "user/model" # owned by user'); + expect(injected).toContain('default_subagent_reasoning_effort = "medium"'); + expect(injected).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).not.toContain('default_subagent_model = "gpt-5.6-sol"'); + }); + + test("sync-disabled injection cleans managed-default residue before journaling and restore", () => { + const residue = [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "stale/routed-model"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high"', + "", + "[features]", + "fast_mode = true", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), residue, "utf8"); + + const injectedResult = runInject(codexHome, ocxHome, "{}"); + expect(injectedResult.status).toBe(0); + expect(JSON.parse(injectedResult.stdout).success).toBe(true); + const injected = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(injected).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(injected).not.toContain("default_subagent_model"); + expect(() => Bun.TOML.parse(injected)).not.toThrow(); + + const restoredResult = runRestore(codexHome, ocxHome); + expect(restoredResult.status).toBe(0); + expect(JSON.parse(restoredResult.stdout).success).toBe(true); + const restored = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(restored).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(restored).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(restored).not.toContain("default_subagent_model"); + expect(restored).toContain("[features]\nfast_mode = true"); + }); + + test("ambiguous managed-default residue refuses injection without changing files", () => { + const ambiguous = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "stale/routed-model"', + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), ambiguous, "utf8"); + + const result = runInject(codexHome, ocxHome, "{}"); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.success).toBe(false); + expect(payload.message).toContain("injection refused"); + expect(payload.message).toContain("orphaned managed subagent default marker"); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(ambiguous); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + expect(existsSync(join(codexHome, "opencodex-journal.json"))).toBe(false); + }); + test("kept-user-base-url: reports routing NOT injected and leaves the user's override alone", () => { writeFileSync(join(codexHome, "config.toml"), [ 'openai_base_url = "https://my-own-gateway.example/v1"', @@ -103,16 +225,23 @@ describe("injectCodexConfig integration (Design B)", () => { "", ].join("\n"), "utf8"); - const r = runInject(codexHome, ocxHome); + const r = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); expect(r.status).toBe(0); const result = JSON.parse(r.stdout); expect(result.success).toBe(true); expect(result.message).toContain("routing NOT injected"); expect(result.message).not.toContain("All models now route through opencodex proxy"); + expect(result.nativeSubagentDefaultsWarning).toContain("user-owned root openai_base_url"); const config = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(config).toContain('openai_base_url = "https://my-own-gateway.example/v1"'); expect(config).not.toContain("# Auto-injected by opencodex\nopenai_base_url"); + expect(config).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(config).not.toContain("default_subagent_model"); }); test("external model provider stays byte-for-byte unchanged so its session history remains visible", () => { @@ -158,7 +287,11 @@ describe("injectCodexConfig integration (Design B)", () => { timestamp: new Date().toISOString(), }), "utf8"); - const r = runInject(codexHome, ocxHome); + const r = runInject(codexHome, ocxHome, JSON.stringify({ + syncCodexSubagentDefaults: true, + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + })); expect(r.status).toBe(0); const result = JSON.parse(r.stdout); expect(result.success).toBe(true); @@ -166,6 +299,7 @@ describe("injectCodexConfig integration (Design B)", () => { expect(result.message).toContain('external model_provider "custom"'); expect(result.message).toContain("http://127.0.0.1:10100/v1"); expect(result.message).toContain("Responses passthrough"); + expect(result.nativeSubagentDefaultsWarning).toContain("external model_provider"); expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); expect(readFileSync(profilePath, "utf8")).toBe(profile); diff --git a/tests/codex-inject.test.ts b/tests/codex-inject.test.ts index d912f8d538..d8ab1f0cf7 100644 --- a/tests/codex-inject.test.ts +++ b/tests/codex-inject.test.ts @@ -11,6 +11,10 @@ import { stripOpencodexConfig, stripRootContextWindowOverrides, } from "../src/codex/inject"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; describe("Codex config injection", () => { test("omits provider-level Responses WebSocket support by default", () => { @@ -184,6 +188,26 @@ describe("Codex config injection", () => { expect(stripped).not.toContain("[model_providers.opencodex]"); expect(stripped).not.toContain("[profiles.opencodex]"); }); + + test("strip removes only marker-owned native subagent defaults", () => { + const stripped = stripOpencodexConfig([ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "gpt-5.6-sol"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high"', + "max_threads = 8", + "", + ].join("\n")); + + expect(stripped).toContain("[agents]"); + expect(stripped).toContain("max_threads = 8"); + expect(stripped).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + expect(stripped).not.toContain(MANAGED_SUBAGENT_DEFAULT_MARKER); + expect(stripped).not.toContain("default_subagent_model"); + expect(stripped).not.toContain("default_subagent_reasoning_effort"); + }); }); describe("Design B openai_base_url injection", () => { diff --git a/tests/codex-journal.test.ts b/tests/codex-journal.test.ts index 6280927c8b..a496a4163b 100644 --- a/tests/codex-journal.test.ts +++ b/tests/codex-journal.test.ts @@ -4,6 +4,10 @@ import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, +} from "../src/codex/subagent-defaults"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -132,6 +136,140 @@ describe("codex-journal", () => { expect(JSON.parse(r.stdout).exists).toBe(false); }); + test("removeCodexConfig is a successful no-op when Codex is not installed", () => { + writeFileSync(join(testDir, "opencodex.config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8"); + rmSync(join(testDir, "config.toml")); + const r = runScript(testDir, ` + const { removeCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + console.log(JSON.stringify({ remove: removeCodexConfig(), restore: restoreNativeCodex() })); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.remove.success).toBe(true); + expect(result.remove.message).toContain("no native restore was needed"); + expect(result.restore.success).toBe(true); + expect(existsSync(join(testDir, "opencodex.config.toml"))).toBe(false); + }); + + test("removeCodexConfig reports damaged managed-default cleanup and preserves the ambiguous value", () => { + writeFileSync(join(testDir, "config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + "", + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + + const r = runScript(testDir, ` + const { removeCodexConfig } = require("./src/codex/inject"); + console.log(JSON.stringify(removeCodexConfig())); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(false); + expect(result.message).toContain("could not be safely removed"); + expect(result.message).toContain("orphaned managed subagent default marker"); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain("# Managed by opencodex: native subagent default"); + expect(after).toContain('default_subagent_model = "gpt-5.6-sol"'); + }); + + test("removeCodexConfig ignores unsupported user-owned agents syntax when no managed marker exists", () => { + const userAgents = 'agents = { default_subagent_model = "user/model" }'; + writeFileSync(join(testDir, "config.toml"), [ + "# Auto-injected by opencodex", + 'openai_base_url = "http://127.0.0.1:10100/v1"', + userAgents, + "", + ].join("\n"), "utf8"); + + const r = runScript(testDir, ` + const { removeCodexConfig } = require("./src/codex/inject"); + console.log(JSON.stringify(removeCodexConfig())); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain(userAgents); + }); + + test("restoreNativeCodex restores an exact unchanged journal snapshot with managed defaults", () => { + const original = '# original config\nmodel_provider = "openai"\n'; + writeFileSync(join(testDir, "config.toml"), original, "utf8"); + + const r = runScript(testDir, ` + const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + (async () => { + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); + console.log(JSON.stringify(restoreNativeCodex())); + })(); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + expect(result.message).toContain("restored from opencodex journal"); + expect(readFileSync(join(testDir, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + }); + + test("restoreNativeCodex reports damaged managed-default cleanup during fallback restore", () => { + const original = '# original config\nmodel_provider = "openai"\n'; + writeFileSync(join(testDir, "config.toml"), original, "utf8"); + + const r = runScript(testDir, ` + const fs = require("fs"); + const path = require("path"); + const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); + (async () => { + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); + const marker = ${JSON.stringify(MANAGED_SUBAGENT_DEFAULT_MARKER)}; + const injected = fs.readFileSync(configPath, "utf8"); + fs.writeFileSync(configPath, injected.replace( + marker + '\\ndefault_subagent_model', + marker + '\\n\\ndefault_subagent_model', + ), "utf8"); + console.log(JSON.stringify(restoreNativeCodex())); + })(); + `); + + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(false); + expect(result.message).toContain("could not be safely removed"); + expect(result.message).toContain("orphaned managed subagent default marker"); + const after = readFileSync(join(testDir, "config.toml"), "utf8"); + expect(after).not.toContain("openai_base_url"); + expect(after).toContain("# Managed by opencodex: native subagent default"); + expect(after).toContain('default_subagent_model = "gpt-5.6-sol"'); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); + }); + test("restoreNativeCodex uses journal snapshot for normal stop without losing custom defaults", () => { const originalConfig = [ 'model = "openrouter/foo"', @@ -214,7 +352,14 @@ describe("codex-journal", () => { const path = require("path"); const { injectCodexConfig, restoreNativeCodex } = require("./src/codex/inject"); (async () => { - await injectCodexConfig(10100, { port: 10100, providers: {}, defaultProvider: "openai" }, { catalogPath: null }); + await injectCodexConfig(10100, { + port: 10100, + providers: {}, + defaultProvider: "openai", + injectionModel: "gpt-5.6-sol", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }, { catalogPath: null }); fs.appendFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "\\n[tools]\\nweb_search = true\\n", "utf8"); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); @@ -226,6 +371,9 @@ describe("codex-journal", () => { expect(restored).toContain("[tools]"); expect(restored).toContain("web_search = true"); expect(restored).not.toContain("[model_providers.opencodex]"); + expect(restored).not.toContain("Managed by opencodex: native subagent"); + expect(restored).not.toContain("default_subagent_model"); + expect(restored).not.toContain("default_subagent_reasoning_effort"); expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); }); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 31d23ff4b5..24f3cd58e0 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { syncModelsToCodex } from "../src/codex/sync"; +import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../src/codex/subagent-defaults"; import type { OcxConfig } from "../src/types"; import type { OrcaCodexHomeDiagnostic } from "../src/codex/home"; @@ -163,6 +165,65 @@ describe("GUI/CLI Codex sync backend", () => { expect(result.warning).toContain("catalog boom"); }); + test("returns native subagent default conflicts as structured warnings", async () => { + const result = await syncModelsToCodex(10100, config, null, { + refreshCodexModelCatalog: async () => ({ + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + cacheSynced: true, + }), + injectCodexConfig: async () => ({ + success: true, + message: "injected with a preserved user setting", + nativeSubagentDefaultsWarning: "Native Codex sub-agent defaults were not injected: user-owned agents.default_subagent_model preserved.", + }), + currentExternalCodexModelProvider: () => null, + }); + + expect(result.ok).toBe(true); + expect(result.nativeSubagentDefaultsWarning).toContain("user-owned agents.default_subagent_model preserved"); + }); + + test("POST /api/sync exposes an actionable error when native defaults are ambiguous", () => { + const ocxHome = join(TEST_DIR, "opencodex"); + mkdirSync(ocxHome, { recursive: true }); + writeFileSync(join(TEST_CODEX_HOME, "config.toml"), [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "", + 'default_subagent_model = "gpt-5.6-sol"', + "", + ].join("\n"), "utf8"); + + const child = spawnSync(process.execPath, ["-e", ` + const { handleManagementAPI } = await import("./src/server/management-api.ts"); + const config = { port: 10100, defaultProvider: "openai", providers: {} }; + const response = await handleManagementAPI( + new Request("http://localhost/api/sync", { method: "POST" }), + new URL("http://localhost/api/sync"), + config, + ); + console.log(JSON.stringify({ status: response.status, body: await response.json() })); + `], { + cwd: join(import.meta.dir, ".."), + env: { ...process.env, CODEX_HOME: TEST_CODEX_HOME, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + }); + + expect(child.status).toBe(0); + const payload = JSON.parse(child.stdout.trim()) as { + status: number; + body: { ok: boolean; error?: string; message: string }; + }; + expect(payload.status).toBe(500); + expect(payload.body.ok).toBe(false); + expect(payload.body.error).toBe(payload.body.message); + expect(payload.body.error).toContain("inspect"); + expect(payload.body.error).toContain(join(TEST_CODEX_HOME, "config.toml")); + }); + test("skips catalog refresh before preserving an external provider", async () => { let refreshed = false; let injectedCatalogPath: string | null | undefined = "unset"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 1224c15f4f..a53ae79b70 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -174,6 +174,144 @@ describe("opencodex config defaults", () => { } }); + test("native subagent-default sync is opt-in and ignores malformed opt-ins without falling back", () => { + const base = { + port: 12345, + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://example.test/v1", + }, + }, + defaultProvider: "custom", + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + injectionModel: "gpt-5.6-terra", + }; + expect(getDefaultConfig().syncCodexSubagentDefaults).toBeUndefined(); + + for (const enabled of [true, false]) { + writeConfig({ ...base, syncCodexSubagentDefaults: enabled }); + expect(loadConfig().syncCodexSubagentDefaults).toBe(enabled); + } + + for (const invalid of [null, "true", 1]) { + writeConfig({ ...base, syncCodexSubagentDefaults: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics).toMatchObject({ + source: "file", + error: null, + config: { + port: 12345, + defaultProvider: "custom", + providers: { custom: { baseUrl: "https://example.test/v1" } }, + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + injectionModel: "gpt-5.6-terra", + }, + }); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: expected a boolean"); + expect(loadConfig()).toMatchObject({ + port: 12345, + defaultProvider: "custom", + providers: { custom: { baseUrl: "https://example.test/v1" } }, + codexAccounts: [{ id: "account-1", email: "owner@example.test", isMain: true }], + }); + expect(backupNames()).toEqual([]); + } + }); + + test("validates disk injection selections and safely normalizes a model-less sync opt-in", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + injectionEffort: "ultra", + syncCodexSubagentDefaults: true, + }); + expect(loadConfig()).toMatchObject({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "ultra", + syncCodexSubagentDefaults: true, + }); + + for (const invalid of ["", " "]) { + writeConfig({ ...base, injectionModel: invalid, syncCodexSubagentDefaults: true }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.injectionModel).toBe(invalid); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: a nonblank injectionModel is required"); + } + + for (const invalid of ["", "turbo"]) { + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + injectionEffort: invalid, + syncCodexSubagentDefaults: true, + }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.injectionEffort).toBe(invalid); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain("syncCodexSubagentDefaults ignored: injectionEffort must be a supported Codex reasoning effort"); + } + + for (const [field, invalid] of [["injectionModel", 1], ["injectionEffort", 1]] as const) { + writeConfig({ + ...base, + injectionModel: "gpt-5.6-terra", + syncCodexSubagentDefaults: true, + [field]: invalid, + }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.port).toBe(10100); + expect(diagnostics.config.defaultProvider).toBe("openai"); + expect(diagnostics.config.providers.openai.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + expect(diagnostics.config[field]).toBeUndefined(); + expect(diagnostics.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(diagnostics.warnings).toContain(`${field} ignored: expected a string`); + expect(diagnostics.warnings?.some(warning => warning.startsWith("syncCodexSubagentDefaults ignored:"))).toBe(true); + expect(loadConfig()).toMatchObject({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { baseUrl: "https://chatgpt.com/backend-api/codex" } }, + }); + expect(backupNames()).toEqual([]); + } + + // Guidance-only values retain their pre-existing compatibility. They are + // constrained only when the native Codex config mutation is opted into. + writeConfig({ ...base, injectionModel: "legacy/model", injectionEffort: "provider-specific" }); + expect(readConfigDiagnostics()).toMatchObject({ + source: "file", + error: null, + config: { injectionModel: "legacy/model", injectionEffort: "provider-specific" }, + }); + + writeConfig({ ...base, syncCodexSubagentDefaults: true }); + const normalized = readConfigDiagnostics(); + expect(normalized.source).toBe("file"); + expect(normalized.error).toBeNull(); + expect(normalized.config.syncCodexSubagentDefaults).toBeUndefined(); + expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index 8e24a7c2ef..331ca11129 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -91,11 +91,26 @@ describe("Grok fence lifecycle wiring", () => { expect(restartCase).toContain("if (await handleStop()) await handleEnsure()"); }); + test("handleStop treats an incomplete native Codex restore as a stop failure", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + expect(stopFn).toContain("if (r.success) console.log"); + expect(stopFn).toContain("stopFailed = true"); + expect(stopFn).toContain("console.error(`⚠️ ${r.message}`)"); + }); + test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { const startFn = sliceFn(CLI_SOURCE, "const syncCleanup = () => {", "let shuttingDown = false;"); // Crash/respawn under a service manager must still keep the fence. expect(startFn).toContain("!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()"); }); + + test("signal shutdown reports and exits nonzero when native Codex restore is incomplete", () => { + const startFn = sliceFn(CLI_SOURCE, "async function handleStart(", "async function handleStop("); + expect(startFn).toContain("if (!restored.success)"); + expect(startFn).toContain("cleanupSucceeded = false"); + expect(startFn).toContain("Native Codex restore failed during shutdown"); + expect(startFn).toContain("process.exit(restored ? 0 : 1)"); + }); }); describe("service teardown owns both managed configs", () => { diff --git a/tests/injection-model-api.test.ts b/tests/injection-model-api.test.ts index 3a555b3d22..e324ea6250 100644 --- a/tests/injection-model-api.test.ts +++ b/tests/injection-model-api.test.ts @@ -47,7 +47,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig(); const putRes = await put(config, { model: "openai/gpt-5.6-sol", effort: "xhigh" }); - expect(await putRes.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "openai/gpt-5.6-sol", effort: "xhigh", prompt: null }); + expect(await putRes.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "openai/gpt-5.6-sol", effort: "xhigh", prompt: null }); expect(config.injectionEffort).toBe("xhigh"); const getRes = await handleManagementAPI( @@ -89,7 +89,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "max" }); const res = await put(config, { model: "openai/gpt-5.6-sol", effort: null }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "openai/gpt-5.6-sol", effort: null, prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "openai/gpt-5.6-sol", effort: null, prompt: null }); expect(config.injectionEffort).toBeUndefined(); }); @@ -97,7 +97,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "max" }); const res = await put(config, { model: null }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: null, effort: null, prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: null, effort: null, prompt: null }); expect(config.injectionModel).toBeUndefined(); expect(config.injectionEffort).toBeUndefined(); }); @@ -106,7 +106,7 @@ describe("/api/injection-model reasoning effort", () => { isolatedHome(); const config = makeConfig({ injectionModel: "openai/gpt-5.6-sol", injectionEffort: "ultra" }); const res = await put(config, { model: "anthropic/claude-sonnet-5" }); - expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, model: "anthropic/claude-sonnet-5", effort: "ultra", prompt: null }); + expect(await res.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: true, syncCodexSubagentDefaults: false, model: "anthropic/claude-sonnet-5", effort: "ultra", prompt: null }); }); test("GET round-trips combo aliases and excludes an alias-disabled combo", async () => { @@ -163,6 +163,7 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(await response.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: false, model: "gpt-5.6-terra", effort: "max", prompt: "RULES {{model}} {{roster}}", @@ -195,6 +196,7 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(await response.json()).toEqual({ ok: true, multiAgentGuidanceEnabled: false, + syncCodexSubagentDefaults: false, model: null, effort: null, prompt: "RULES {{roster}}", @@ -205,6 +207,148 @@ describe("/api/injection-model guidance kill switch + partial update", () => { expect(config.multiAgentGuidanceEnabled).toBe(false); }); + test("subagent-default sync is opt-in, model-bound, partial, and normalized on disk", async () => { + isolatedHome(); + const config = makeConfig(); + + let response = await handleManagementAPI( + new Request("http://localhost/api/injection-model"), + new URL("http://localhost/api/injection-model"), + config, + ); + expect(await response!.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + }); + + response = await put(config, { syncCodexSubagentDefaults: true }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "syncCodexSubagentDefaults requires an injection model" }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(existsSync(getConfigPath())).toBe(false); + + response = await put(config, { syncCodexSubagentDefaults: "true" }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "syncCodexSubagentDefaults must be a boolean" }); + + response = await put(config, { + model: "gpt-5.6-terra", + effort: "max", + syncCodexSubagentDefaults: true, + }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: true, + model: "gpt-5.6-terra", + effort: "max", + }); + expect(config.syncCodexSubagentDefaults).toBe(true); + + response = await put(config, { effort: "high" }); + expect(await response.json()).toMatchObject({ syncCodexSubagentDefaults: true, effort: "high" }); + expect(config.syncCodexSubagentDefaults).toBe(true); + + response = await put(config, { syncCodexSubagentDefaults: false }); + expect(await response.json()).toMatchObject({ syncCodexSubagentDefaults: false, model: "gpt-5.6-terra" }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("clearing the injection model also clears subagent-default sync", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "max", + syncCodexSubagentDefaults: true, + }); + + const response = await put(config, { model: null }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + effort: null, + }); + expect(config.injectionModel).toBeUndefined(); + expect(config.injectionEffort).toBeUndefined(); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("clearing model with sync on and unsupported inherited effort returns 200 and clears all", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "legacy/model", + injectionEffort: "provider-specific", + syncCodexSubagentDefaults: true, + }); + + const response = await put(config, { model: null }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + effort: null, + }); + expect(config.injectionModel).toBeUndefined(); + expect(config.injectionEffort).toBeUndefined(); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + }); + + test("a latent model-less sync flag stays off during a model-only partial update", async () => { + isolatedHome(); + const config = makeConfig({ syncCodexSubagentDefaults: true }); + + let response = await handleManagementAPI( + new Request("http://localhost/api/injection-model"), + new URL("http://localhost/api/injection-model"), + config, + ); + expect(await response!.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: null, + }); + + response = await put(config, { model: "gpt-5.6-terra" }); + expect(await response.json()).toMatchObject({ + syncCodexSubagentDefaults: false, + model: "gpt-5.6-terra", + }); + expect(config.syncCodexSubagentDefaults).toBeUndefined(); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).not.toHaveProperty("syncCodexSubagentDefaults"); + }); + + test("rejects a whitespace-only model without mutating existing settings", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "gpt-5.6-terra", + injectionEffort: "high", + syncCodexSubagentDefaults: true, + }); + const before = structuredClone(config); + + const response = await put(config, { model: " " }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "model must be a nonblank string or null" }); + expect(config).toEqual(before); + expect(existsSync(getConfigPath())).toBe(false); + }); + + test("rejects native-default opt-in when a legacy stored effort is not Codex-supported", async () => { + isolatedHome(); + const config = makeConfig({ + injectionModel: "legacy/model", + injectionEffort: "provider-specific", + }); + const before = structuredClone(config); + + const response = await put(config, { syncCodexSubagentDefaults: true }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort", + }); + expect(config).toEqual(before); + expect(existsSync(getConfigPath())).toBe(false); + }); + test.each([ ["null", null], ["array", []], diff --git a/tests/subagent-defaults.test.ts b/tests/subagent-defaults.test.ts new file mode 100644 index 0000000000..96adcf2ae2 --- /dev/null +++ b/tests/subagent-defaults.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "bun:test"; +import { + MANAGED_AGENTS_TABLE_MARKER, + MANAGED_SUBAGENT_DEFAULT_MARKER, + transformManagedSubagentDefaults, +} from "../src/codex/subagent-defaults"; + +function apply(content: string, model = "openai/gpt-5.6-sol", reasoningEffort?: string) { + return transformManagedSubagentDefaults(content, { model, reasoningEffort }); +} + +describe("managed native subagent defaults TOML transform", () => { + test("creates an owned [agents] table and TOML-escapes supplied strings", () => { + const input = 'model = "gpt-5.6"\n'; + const result = apply(input, 'provider/a"b\\c\nnext\u007f', "xhigh"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.conflicts).toEqual([]); + expect(result.content).toBe([ + 'model = "gpt-5.6"', + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "provider/a\\"b\\\\c\\nnext\\u007F"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "xhigh"', + "", + ].join("\n")); + expect((Bun.TOML.parse(result.content).agents as Record).default_subagent_model) + .toBe('provider/a"b\\c\nnext\u007f'); + expect(apply(result.content, 'provider/a"b\\c\nnext\u007f', "xhigh")).toEqual({ + ok: true, + changed: false, + content: result.content, + conflicts: [], + }); + }); + + test("updates only marked values while preserving comments, siblings, and table order", () => { + const input = [ + "# before", + "[agents] # native settings", + "max_threads = 12 # user", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "default_subagent_model = 'old/model' # keep this comment", + "custom = { nested = true }", + "", + "[notice]", + "hide = false", + "", + ].join("\n"); + const result = apply(input, "new/model"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('default_subagent_model = "new/model" # keep this comment'); + expect(result.content).toContain("max_threads = 12 # user\n"); + expect(result.content).toContain("custom = { nested = true }\n\n[notice]\nhide = false\n"); + expect(result.content).not.toContain("default_subagent_reasoning_effort"); + }); + + test("preserves CRLF on insertion and update", () => { + const input = `[agents]\r\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\r\ndefault_subagent_model = "old"\r\nmax_threads = 4\r\n`; + const result = apply(input, "new", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('default_subagent_model = "new"\r\n'); + expect(result.content).toContain('default_subagent_reasoning_effort = "high"\r\n'); + expect(result.content).not.toMatch(/[^\r]\n/); + }); + + test("omitting effort removes only a marked effort and leaves an unmarked sibling", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "old"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = "high" # owned', + "max_depth = 3 # user", + "", + ].join("\n"); + const result = apply(input, "new"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).not.toContain("default_subagent_reasoning_effort"); + expect(result.content).toContain("max_depth = 3 # user"); + }); + + test("reports unmarked target keys as conflicts without overwriting them", () => { + const input = '[agents]\ndefault_subagent_model = "user/model" # user-owned\nmax_threads = 8\n'; + const result = apply(input, "managed/model", "medium"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.conflicts).toEqual([ + { key: "default_subagent_model", line: 2, reason: "user-owned" }, + ]); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + }); + + test("an effort conflict also prevents updating the managed model half", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "old/managed"', + 'default_subagent_reasoning_effort = "user-effort"', + "", + ].join("\n"); + const result = apply(input, "new/managed", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [ + { key: "default_subagent_reasoning_effort", line: 4, reason: "user-owned" }, + ], + }); + }); + + test("clear removes owned pairs but preserves user-owned targets", () => { + const input = [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + 'default_subagent_reasoning_effort = "user-effort" # user-owned', + "max_threads = 8", + "", + ].join("\n"); + const result = transformManagedSubagentDefaults(input, null); + expect(result).toEqual({ + ok: true, + changed: true, + content: '[agents]\ndefault_subagent_reasoning_effort = "user-effort" # user-owned\nmax_threads = 8\n', + conflicts: [], + }); + }); + + test("clear ignores unsupported user-owned shapes when no managed marker exists", () => { + const input = [ + 'agents = { default_subagent_model = "user/model" }', + 'message = """', + MANAGED_SUBAGENT_DEFAULT_MARKER, + "[agents]", + 'default_subagent_model = "string/data"', + '"""', + "", + ].join("\n"); + expect(transformManagedSubagentDefaults(input, null)).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [], + }); + }); + + test("clear removes an empty marker-owned table and is idempotent", () => { + const enabled = apply(""); + expect(enabled.ok).toBe(true); + if (!enabled.ok) return; + const cleared = transformManagedSubagentDefaults(enabled.content, null); + expect(cleared).toEqual({ ok: true, changed: true, content: "", conflicts: [] }); + expect(transformManagedSubagentDefaults(cleared.content, null)).toEqual({ + ok: true, + changed: false, + content: "", + conflicts: [], + }); + }); + + test("a marker-owned table with unknown content survives clear", () => { + const input = [ + MANAGED_AGENTS_TABLE_MARKER, + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + "max_threads = 7 # user extension", + "", + ].join("\n"); + const result = transformManagedSubagentDefaults(input, null); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toBe("[agents]\nmax_threads = 7 # user extension\n"); + expect(result.content).not.toContain(MANAGED_AGENTS_TABLE_MARKER); + }); + + test("inserts a base table before nested custom-role tables and preserves them", () => { + const input = [ + 'model = "gpt-5.6"', + "", + "[agents.reviewer] # custom role", + 'description = "review only"', + "", + '[agents."executor"]', + 'description = "execute"', + "", + ].join("\n"); + const result = apply(input, "managed/model", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content.indexOf("[agents]")).toBeLessThan(result.content.indexOf("[agents.reviewer]")); + expect(result.content).toContain('[agents.reviewer] # custom role\ndescription = "review only"'); + expect(result.content).toContain('[agents."executor"]\ndescription = "execute"'); + expect(apply(result.content, "managed/model", "high").changed).toBe(false); + }); + + test("supports quoted canonical table and key names", () => { + const input = `["agents"]\n${MANAGED_SUBAGENT_DEFAULT_MARKER}\n"default_subagent_model" = "old"\n`; + const result = apply(input, "new"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain('"default_subagent_model" = "new"'); + }); + + test.each([ + ["basic", 'message = """\n[agents]\n# Managed by opencodex: native subagent default\ndefault_subagent_model = "string/data"\n"""\n'], + ["literal", "message = '''\n[agents]\n# Managed by opencodex: native subagent default\ndefault_subagent_model = 'string/data'\n'''\n"], + ])("ignores table, key, and marker text inside %s multiline strings", (_kind, input) => { + const before = Bun.TOML.parse(input).message; + const result = apply(input, "managed/model", "high"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Bun.TOML.parse(result.content).message).toBe(before); + expect(result.content).toStartWith(input); + expect(result.content.match(/^\[agents\]$/gm)).toHaveLength(2); + const parsedAgents = Bun.TOML.parse(result.content).agents as Record; + expect(parsedAgents.default_subagent_model).toBe("managed/model"); + expect(parsedAgents.default_subagent_reasoning_effort).toBe("high"); + }); + + test("recognizes an escaped quoted user key as the canonical target", () => { + const input = String.raw`[agents] +"default_subagent_\u006dodel" = "user/model" +`; + const result = apply(input, "managed/model", "high"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 2, reason: "user-owned" }], + }); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test.each([ + ["x", String.raw`[agents] +"default_subagent_\x6dodel" = "user/model" +`], + ["U", String.raw`["\U00000061gents"] +"default_subagent_\U0000006Dodel" = "user/model" +`], + ])("recognizes Codex-supported \\%s escapes in quoted canonical keys", (_escape, input) => { + const result = apply(input, "managed/model"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 2, reason: "user-owned" }], + }); + }); + + test("recognizes an escaped quoted agents table without creating a duplicate", () => { + const input = String.raw`["\u0061gents"] +max_threads = 4 +`; + const result = apply(input, "managed/model"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.content).toContain(String.raw`["\u0061gents"]`); + expect(result.content).not.toContain("\n[agents]\n"); + expect((Bun.TOML.parse(result.content).agents as Record).default_subagent_model) + .toBe("managed/model"); + }); + + test("does not treat nested array elements as table boundaries", () => { + const input = [ + "[agents]", + "matrix = [", + ' ["x"],', + "]", + 'default_subagent_model = "user/model"', + "", + ].join("\n"); + const result = apply(input, "managed/model", "high"); + expect(result).toEqual({ + ok: true, + changed: false, + content: input, + conflicts: [{ key: "default_subagent_model", line: 5, reason: "user-owned" }], + }); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test("finds root dotted targets after a multiline nested array", () => { + const input = [ + "matrix = [", + ' ["x"],', + "]", + 'agents.default_subagent_model = "user/model"', + "", + ].join("\n"); + const result = apply(input); + expect(result.ok).toBe(false); + expect(result.content).toBe(input); + }); + + test.each([ + ["model", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = """', + "managed/model", + '"""', + "", + ].join("\n"), null], + ["literal model", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + "default_subagent_model = '''", + "managed/model", + "'''", + "", + ].join("\n"), null], + ["effort", [ + "[agents]", + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_model = "managed/model"', + MANAGED_SUBAGENT_DEFAULT_MARKER, + 'default_subagent_reasoning_effort = """', + "high", + '"""', + "", + ].join("\n"), { model: "managed/model" }], + ])("refuses to partially remove a marker-owned multiline %s", (_kind, input, defaults) => { + const result = transformManagedSubagentDefaults(input, defaults); + expect(result.ok).toBe(false); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + expect(() => Bun.TOML.parse(result.content)).not.toThrow(); + }); + + test.each([ + ["duplicate tables", "[agents]\n[agents]\n"], + ["duplicate target keys", '[agents]\ndefault_subagent_model = "a"\ndefault_subagent_model = "b"\n'], + ["root dotted target", 'agents.default_subagent_model = "a"\n'], + ["quoted root dotted target", '"agents"."default_subagent_model" = "a"\n'], + ["escaped quoted root dotted target", String.raw`"\u0061gents"."default_subagent_\u006dodel" = "a" +`], + ["dotted target in table", '[agents]\ndefault_subagent_model.value = "a"\n'], + ["target key as dotted table", "[agents.default_subagent_model]\nvalue = 'a'\n"], + ["target key as deeper dotted table", "[agents.default_subagent_model.metadata]\nvalue = 'a'\n"], + ["array agents table", "[[agents]]\nmodel = 'a'\n"], + ["inline agents definition", 'agents = { default_subagent_model = "a" }\n'], + ["orphaned key marker", `${MANAGED_SUBAGENT_DEFAULT_MARKER}\n[notice]\n`], + ["orphaned table marker", `${MANAGED_AGENTS_TABLE_MARKER}\n[notice]\n`], + ])("rejects %s without changing bytes", (_name, input) => { + const result = apply(input); + expect(result.ok).toBe(false); + expect(result.changed).toBe(false); + expect(result.content).toBe(input); + }); + + test("rejects invalid requested defaults without changing bytes", () => { + const input = "# untouched\r\n"; + expect(transformManagedSubagentDefaults(input, { model: "" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + expect(transformManagedSubagentDefaults(input, { model: "m", reasoningEffort: "" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + expect(transformManagedSubagentDefaults(input, { model: "bad\ud800" })).toMatchObject({ + ok: false, + changed: false, + content: input, + }); + }); +});