From f85a560e9b4ed2f16eb2855b4e6c11b7d368dfc5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 14 Oct 2026 23:59:13 -0700 Subject: [PATCH 1/4] fix(codex): honor canonical subagent limits across modes Read and write the canonical Codex thread limit consistently with supported aliases. Keep the subagent selector available across modes, normalize child-count units, and reject stale settings responses. Cover canonical settings, mode behavior, and response ordering with focused regressions and update the capacity documentation. --- .../docs/fr/guides/subagent-v1-default.md | 11 + .../docs/guides/subagent-v1-default.md | 10 + .../docs/ja/guides/subagent-v1-default.md | 6 + .../docs/ko/guides/subagent-v1-default.md | 6 + .../docs/ru/guides/subagent-v1-default.md | 10 + .../docs/tr/guides/subagent-v1-default.md | 10 + .../docs/zh-cn/guides/subagent-v1-default.md | 6 + .../docs/zh-tw/guides/subagent-v1-default.md | 6 + gui/src/i18n/de.ts | 8 +- gui/src/i18n/en.ts | 8 +- gui/src/i18n/fr.ts | 8 +- gui/src/i18n/ja.ts | 8 +- gui/src/i18n/ko.ts | 8 +- gui/src/i18n/pt.ts | 8 +- gui/src/i18n/ru.ts | 8 +- gui/src/i18n/tr.ts | 8 +- gui/src/i18n/vi.ts | 8 +- gui/src/i18n/zh-TW.ts | 8 +- gui/src/i18n/zh.ts | 8 +- gui/src/pages/Models.tsx | 103 +++--- gui/src/pages/models-settings-summary.ts | 4 +- gui/src/pages/models-shared.ts | 18 + gui/tests/models-settings-panel.test.tsx | 5 +- gui/tests/models-subagent-limit.test.tsx | 330 ++++++++++++++++++ scripts/test-layout/layout.json | 1 + src/codex/features.ts | 130 ++++--- structure/INDEX.md | 3 +- structure/gui-and-management-api.md | 2 +- structure/manifest.json | 9 + structure/subagent-capacity.md | 75 ++++ structure/subagents.md | 55 +-- tests/cli/cli-v2-runtime.test.ts | 34 +- .../codex-agent-thread-limit.test.ts | 178 ++++++++++ tests/codex-integration/codex-v2-gate.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 1 + 35 files changed, 907 insertions(+), 196 deletions(-) create mode 100644 gui/tests/models-subagent-limit.test.tsx create mode 100644 structure/subagent-capacity.md create mode 100644 tests/codex-integration/codex-agent-thread-limit.test.ts diff --git a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md index cafcd8b6a32..d93aa422837 100644 --- a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md @@ -148,3 +148,14 @@ ocx v2 mode v1 La CLI ne demande pas de confirmation. C'est le même paramètre : choisissez-le en tenant compte de ce que décrit cette page. + +## Limite de sous-agents simultanés + +Dans **Models → Model settings**, le réglage de limite des sous-agents définit le nombre maximal +de sous-agents simultanés, sans compter l'agent principal. Il reste disponible en v1, base et v2, +y compris en mode hybride lorsque le drapeau v2 global est désactivé. Le choix du mode est +indépendant de cette limite. + +Augmenter la limite autorise davantage de sous-agents, sans les créer automatiquement ni garantir +une exécution plus rapide. Lancez une nouvelle session Codex après modification. Si un hôte App +de longue durée utilise encore l'ancienne limite, redémarrez cet hôte. diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md index 5f4fd79b58f..ab8c6654c6f 100644 --- a/docs-site/src/content/docs/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/guides/subagent-v1-default.md @@ -130,3 +130,13 @@ ocx v2 mode v1 ``` The CLI does not prompt. It is the same setting, so choose it knowing what this page describes. + +## Concurrent child limit + +In **Models → Model settings**, the subagent limit control sets the maximum number of concurrent +child agents, excluding the primary agent. It remains available in v1, base and v2, including +hybrid mode with the global v2 flag disabled. Choose the surface mode separately from this limit. + +Raising the cap permits more children; it does not create them automatically or guarantee faster +execution. Start a new Codex session after changing the limit. If a long-running App host still +uses the previous limit, restart that host. diff --git a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md index 7cdae1cc726..ab3814d4e71 100644 --- a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md @@ -94,3 +94,9 @@ ocx v2 mode v1 ``` CLI は確認を求めません。同じ設定を変更するため、このページの内容を理解したうえで選んでください。 + +## 子エージェントの同時実行上限 + +**Models → Model settings** のサブエージェント上限設定では、主エージェントを除く子エージェントの同時実行数の上限を指定します。v1、base、v2 のほか、グローバル v2 フラグが無効なハイブリッドモードでも利用できます。サーフェスのモードは、この上限とは別に選択します。 + +上限を増やすとより多くの子を許可できますが、自動生成や高速化を保証するものではありません。変更後は新しい Codex セッションを開始してください。長時間動作している App ホストで以前の上限が使われ続ける場合は、そのホストを再起動してください。 diff --git a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md index 9bb8994418d..dbf8fee9103 100644 --- a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md @@ -94,3 +94,9 @@ ocx v2 mode v1 ``` CLI는 확인 메시지를 띄우지 않습니다. 같은 설정이므로 이 문서의 동작을 이해한 뒤 선택하세요. + +## 자식 에이전트 동시 실행 상한 + +**Models → Model settings**의 서브에이전트 상한 설정은 주 에이전트를 제외한 자식 에이전트의 최대 동시 실행 수를 정합니다. v1, base, v2와 전역 v2 플래그가 꺼진 하이브리드 모드에서도 사용할 수 있습니다. 서피스 모드는 이 상한과 별도로 선택합니다. + +상한을 높이면 더 많은 자식을 허용하지만, 자식을 자동으로 만들거나 실행 속도를 보장하지는 않습니다. 변경 후 새 Codex 세션을 시작하세요. 오래 실행된 App 호스트가 이전 상한을 계속 사용한다면 해당 호스트를 다시 시작하세요. diff --git a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md index 022b974c431..8cbc5f3a39a 100644 --- a/docs-site/src/content/docs/ru/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/ru/guides/subagent-v1-default.md @@ -146,3 +146,13 @@ ocx v2 mode v1 CLI подтверждение не запрашивает. Настройка та же, поэтому выбирайте её, учитывая изложенные здесь ограничения. + +## Лимит одновременно работающих подагентов + +В **Models → Model settings** настройка лимита подагентов задаёт максимальное число одновременно +работающих подагентов без учёта основного агента. Она доступна в v1, base и v2, включая гибридный +режим с отключённым глобальным флагом v2. Выбор режима не зависит от этого лимита. + +Повышение лимита разрешает больше подагентов, но не создаёт их автоматически и не гарантирует +ускорения. После изменения начните новую сессию Codex. Если длительно работающий хост App +продолжает использовать прежний лимит, перезапустите этот хост. diff --git a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md index 3249a294bd5..11ebaff1e56 100644 --- a/docs-site/src/content/docs/tr/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/tr/guides/subagent-v1-default.md @@ -130,3 +130,13 @@ ocx v2 mode v1 ``` CLI onay istemez. Aynı ayardır; bu sayfanın anlattıklarını bilerek seçin. + +## Eşzamanlı alt ajan sınırı + +**Models → Model settings** içindeki alt ajan sınırı, ana ajan hariç eşzamanlı alt ajanların +azami sayısını belirler. Genel v2 bayrağı kapalı olan hibrit mod dahil v1, base ve v2'de +kullanılabilir. Arayüz modu bu sınırdan bağımsız olarak seçilir. + +Sınırı artırmak daha fazla alt ajana izin verir; onları otomatik oluşturmaz veya daha hızlı +çalışmayı garanti etmez. Değişiklikten sonra yeni bir Codex oturumu başlatın. Uzun süredir çalışan +App ana bilgisayarı eski sınırı kullanmaya devam ediyorsa o ana bilgisayarı yeniden başlatın. diff --git a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md index bbe261970d2..58fa0890419 100644 --- a/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-cn/guides/subagent-v1-default.md @@ -94,3 +94,9 @@ ocx v2 mode v1 ``` CLI 不会提示。这是同一设置,因此请了解本页描述的行为后再选择。 + +## 子代理并发上限 + +在 **Models → Model settings** 中,子代理上限控件可设置同时运行的子代理数量上限,不包括主代理。v1、base 和 v2 均可使用,包括全局 v2 标志关闭的混合模式。界面模式与此上限分别设置。 + +提高上限可允许更多子代理,但不会自动创建子代理,也不保证执行更快。更改后请启动新的 Codex 会话。如果长时间运行的 App 主机仍使用旧上限,请重新启动该主机。 diff --git a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md index 4215a418350..bdab9f109f1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md +++ b/docs-site/src/content/docs/zh-tw/guides/subagent-v1-default.md @@ -94,3 +94,9 @@ ocx v2 mode v1 ``` CLI 不會提示。這是同一項設定;選擇前請理解本頁所述的影響。 + +## 子代理並行上限 + +在 **Models → Model settings** 中,子代理上限控制項可設定同時執行的子代理數量上限,不包括主代理。v1、base 和 v2 均可使用,包括全域 v2 旗標關閉的混合模式。介面模式與此上限分別設定。 + +提高上限可允許更多子代理,但不會自動建立子代理,也不保證執行更快。變更後請啟動新的 Codex 工作階段。如果長時間執行的 App 主機仍使用舊上限,請重新啟動該主機。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1844dfbaeea..e138211c0e4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -779,10 +779,10 @@ export const de: Record = { "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", "models.v2Applied": "Sub-Agent-Modus aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", - "models.v2ThreadsLabel": "Max. Threads", - "models.v2ThreadsDefault": "Standard (4)", - "models.v2ThreadsApplied": "Thread-Limit aktualisiert — gilt für neue Sitzungen", - "models.v2ThreadsInvalid": "Thread-Limit muss eine ganze Zahl >= 1 sein", + "models.v2ThreadsLabel": "Sub-Agent-Limit", + "models.v2ThreadsDefault": "Standard", + "models.v2ThreadsApplied": "Sub-Agent-Limit aktualisiert — gilt für neue Sitzungen", + "models.v2ThreadsInvalid": "Sub-Agent-Limit muss eine ganze Zahl >= 1 sein", "models.v2ThreadsApply": "Anwenden", "models.capValue": "Standard {value}", "models.contextSettings": "Eigene Fenster", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bff6fef8e56..8c75a474896 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -809,10 +809,10 @@ export const en = { "dash.codexRemoteHistoryHint": "Provider-table routing can hide existing openai-tagged threads in some mobile remote lists. History is not deleted. The remote client must list all providers; this switch does not repair that client filter.", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", - "models.v2ThreadsLabel": "Max threads", - "models.v2ThreadsDefault": "default (4)", - "models.v2ThreadsApplied": "Thread limit updated — applies to new sessions", - "models.v2ThreadsInvalid": "Thread limit must be an integer >= 1", + "models.v2ThreadsLabel": "Sub-agent limit", + "models.v2ThreadsDefault": "default", + "models.v2ThreadsApplied": "Sub-agent limit updated — applies to new sessions", + "models.v2ThreadsInvalid": "Sub-agent limit must be an integer >= 1", "models.v2ThreadsApply": "Apply", "models.capValue": "Default {value}", "models.contextSettings": "Custom windows", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 5681f60e2d2..39466c5ff31 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -794,10 +794,10 @@ export const fr: Record = { "dash.codexRemoteHistoryHint": "Le routage par table de fournisseurs peut masquer les fils existants marqués openai dans certaines listes mobiles distantes. L’historique n’est pas supprimé. Le client distant doit lister tous les fournisseurs ; ce réglage ne corrige pas son filtre.", "models.v2Conflict": "[agents] max_threads est défini — codex refusera de démarrer ; supprimez-le de config.toml", "models.v2Applied": "Mode sous-agent mis à jour — s’applique aux nouvelles sessions (redémarrez l’application Codex pour actualiser le sélecteur)", - "models.v2ThreadsLabel": "Nombre maximal de fils", - "models.v2ThreadsDefault": "par défaut (4)", - "models.v2ThreadsApplied": "Limite de fils mise à jour — s’applique aux nouvelles sessions", - "models.v2ThreadsInvalid": "La limite de fils doit être un entier >= 1", + "models.v2ThreadsLabel": "Limite de sous-agents", + "models.v2ThreadsDefault": "par défaut", + "models.v2ThreadsApplied": "Limite de sous-agents mise à jour — s’applique aux nouvelles sessions", + "models.v2ThreadsInvalid": "La limite de sous-agents doit être un entier >= 1", "models.v2ThreadsApply": "Appliquer", "models.capValue": "Défaut {value}", "models.contextSettings": "Fenêtres perso", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce6e00d9c7e..05070b99b50 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -786,10 +786,10 @@ export const ja: Record = { "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", "models.v2Applied": "サブエージェントモードを更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", - "models.v2ThreadsLabel": "最大スレッド数", - "models.v2ThreadsDefault": "デフォルト (4)", - "models.v2ThreadsApplied": "スレッド上限を更新しました — 新規セッションに適用", - "models.v2ThreadsInvalid": "スレッド上限は 1 以上の整数にしてください", + "models.v2ThreadsLabel": "サブエージェント上限", + "models.v2ThreadsDefault": "デフォルト", + "models.v2ThreadsApplied": "サブエージェント上限を更新しました — 新規セッションに適用", + "models.v2ThreadsInvalid": "サブエージェント上限は 1 以上の整数にしてください", "models.v2ThreadsApply": "適用", "models.capValue": "デフォルト {value}", "models.contextSettings": "カスタムウィンドウ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1e1e77d9f2..a9f549c4c8c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -790,10 +790,10 @@ export const ko: Record = { "dash.multiAgent": "서브에이전트", "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", "models.v2Applied": "서브에이전트 모드 변경됨 — 새 세션부터 적용 (피커 갱신은 Codex 앱 재시작)", - "models.v2ThreadsLabel": "최대 스레드", - "models.v2ThreadsDefault": "기본값 (4)", - "models.v2ThreadsApplied": "스레드 한도 변경됨 — 새 세션부터 적용", - "models.v2ThreadsInvalid": "스레드 한도는 1 이상 정수여야 합니다", + "models.v2ThreadsLabel": "서브에이전트 한도", + "models.v2ThreadsDefault": "기본값", + "models.v2ThreadsApplied": "서브에이전트 한도 변경됨 — 새 세션부터 적용", + "models.v2ThreadsInvalid": "서브에이전트 한도는 1 이상 정수여야 합니다", "models.v2ThreadsApply": "적용", "models.capValue": "기본 {value}", "models.contextSettings": "사용자 지정 창", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index cca8aaa6964..7e9a457b4e7 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -808,10 +808,10 @@ export const pt: Record = { "dash.codexRemoteHistoryHint": "O roteamento por tabela de provedores pode ocultar threads existentes marcadas como openai em algumas listas remotas do celular. O histórico não é excluído. O cliente remoto precisa listar todos os provedores; esta opção não corrige o filtro desse cliente.", "models.v2Conflict": "[agents] max_threads está definido — o codex se recusará a iniciar; remova-o do config.toml", "models.v2Applied": "Modo de subagente atualizado — vale para novas sessões (reinicie o app do Codex para atualizar o seletor)", - "models.v2ThreadsLabel": "Máx. de threads", - "models.v2ThreadsDefault": "padrão (4)", - "models.v2ThreadsApplied": "Limite de threads atualizado — vale para novas sessões", - "models.v2ThreadsInvalid": "O limite de threads deve ser um inteiro >= 1", + "models.v2ThreadsLabel": "Limite de subagentes", + "models.v2ThreadsDefault": "padrão", + "models.v2ThreadsApplied": "Limite de subagentes atualizado — vale para novas sessões", + "models.v2ThreadsInvalid": "O limite de subagentes deve ser um inteiro >= 1", "models.v2ThreadsApply": "Aplicar", "models.capValue": "Padrão {value}", "models.contextSettings": "Janelas personalizadas", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2709713c0bb..5463185e908 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -792,10 +792,10 @@ export const ru: Record = { "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", "models.v2Applied": "Режим подагента обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", - "models.v2ThreadsLabel": "Макс. потоков", - "models.v2ThreadsDefault": "по умолчанию (4)", - "models.v2ThreadsApplied": "Лимит потоков обновлён — применяется к новым сессиям", - "models.v2ThreadsInvalid": "Лимит потоков должен быть целым числом >= 1", + "models.v2ThreadsLabel": "Лимит подагентов", + "models.v2ThreadsDefault": "по умолчанию", + "models.v2ThreadsApplied": "Лимит подагентов обновлён — применяется к новым сессиям", + "models.v2ThreadsInvalid": "Лимит подагентов должен быть целым числом >= 1", "models.v2ThreadsApply": "Применить", "models.capValue": "По умолч. {value}", "models.contextSettings": "Пользовательские окна", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 154d0356c53..56920b2b18a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -795,10 +795,10 @@ export const tr: Record = { "dash.multiAgent": "Alt Ajan", "models.v2Conflict": "[agents] max_threads ayarlanmış — config.toml dosyasından kaldırın", "models.v2Applied": "Çoklu ajan modu güncellendi. Yeni oturumlar bu modu kullanacaktır; model seçiciyi yenilemek için Codex uygulamasını yeniden başlatın.", - "models.v2ThreadsLabel": "Maksimum iş parçacığı", - "models.v2ThreadsDefault": "varsayılan (4)", - "models.v2ThreadsApplied": "İş parçacığı limiti güncellendi", - "models.v2ThreadsInvalid": "İş parçacığı limiti >= 1 tamsayı olmalıdır", + "models.v2ThreadsLabel": "Alt ajan limiti", + "models.v2ThreadsDefault": "varsayılan", + "models.v2ThreadsApplied": "Alt ajan limiti güncellendi", + "models.v2ThreadsInvalid": "Alt ajan limiti >= 1 tamsayı olmalıdır", "models.v2ThreadsApply": "Uygula", "models.capValue": "Varsayılan {value}", "models.contextSettings": "Özel pencereler", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a0b33ac014d..77428a0ab86 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -794,10 +794,10 @@ export const vi: Record = { "dash.codexRemoteHistoryHint": "Định tuyến bằng bảng nhà cung cấp có thể ẩn các luồng mang nhãn openai hiện có khỏi một số danh sách từ xa trên điện thoại. Lịch sử không bị xóa. Client từ xa phải liệt kê mọi nhà cung cấp; công tắc này không sửa bộ lọc của client.", "models.v2Conflict": "[agents] max_threads đã được thiết lập — codex sẽ từ chối khởi động; hãy xoá nó khỏi config.toml", "models.v2Applied": "Chế độ agent con đã được cập nhật — áp dụng cho các phiên mới (khởi động lại ứng dụng Codex để làm mới picker)", - "models.v2ThreadsLabel": "Luồng tối đa (Max threads)", - "models.v2ThreadsDefault": "mặc định (4)", - "models.v2ThreadsApplied": "Giới hạn luồng đã được cập nhật — áp dụng cho các phiên mới", - "models.v2ThreadsInvalid": "Giới hạn luồng phải là số nguyên >= 1", + "models.v2ThreadsLabel": "Giới hạn sub-agent", + "models.v2ThreadsDefault": "mặc định", + "models.v2ThreadsApplied": "Giới hạn sub-agent đã được cập nhật — áp dụng cho các phiên mới", + "models.v2ThreadsInvalid": "Giới hạn sub-agent phải là số nguyên >= 1", "models.v2ThreadsApply": "Áp dụng", "models.capValue": "Mặc định {value}", "models.contextSettings": "Cửa sổ tuỳ chỉnh (Custom windows)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2378eaedfd..2f37a9cf51b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -653,10 +653,10 @@ export const zhTW: Record = { "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 將拒絕啟動,請從 config.toml 移除", "models.v2Applied": "子代理模式已更新 — 新會話生效(重新啟動 Codex 應用以重新整理選擇器)", - "models.v2ThreadsLabel": "最大執行緒", - "models.v2ThreadsDefault": "預設 (4)", - "models.v2ThreadsApplied": "執行緒上限已更新 — 新會話生效", - "models.v2ThreadsInvalid": "執行緒上限必須為 >= 1 的整數", + "models.v2ThreadsLabel": "子代理上限", + "models.v2ThreadsDefault": "預設", + "models.v2ThreadsApplied": "子代理上限已更新 — 新會話生效", + "models.v2ThreadsInvalid": "子代理上限必須為 >= 1 的整數", "models.v2ThreadsApply": "套用", "models.capValue": "預設 {value}", "models.contextCappedValue": "{value} 限制", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cf32dcc5a88..2e2bc4bbc8a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -787,10 +787,10 @@ export const zh: Record = { "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", "models.v2Applied": "子代理模式已更新 — 新会话生效(重启 Codex 应用以刷新选择器)", - "models.v2ThreadsLabel": "最大线程", - "models.v2ThreadsDefault": "默认 (4)", - "models.v2ThreadsApplied": "线程上限已更新 — 新会话生效", - "models.v2ThreadsInvalid": "线程上限必须为 >= 1 的整数", + "models.v2ThreadsLabel": "子代理上限", + "models.v2ThreadsDefault": "默认", + "models.v2ThreadsApplied": "子代理上限已更新 — 新会话生效", + "models.v2ThreadsInvalid": "子代理上限必须为 >= 1 的整数", "models.v2ThreadsApply": "应用", "models.capValue": "默认 {value}", "models.contextSettings": "自定义窗口", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 6c0a446fb81..e4f5104362b 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -62,12 +62,14 @@ import { CAP_OPTION_SET, CAP_OPTIONS, collectDisabledNamespaced, + childCountToThreadLimit, CUSTOM_OPTION, fmtK, NATIVE_CAP_OPTIONS, NATIVE_CAP_OPTION_SET, PAGE, readCollapsedProviders, + threadLimitToChildCount, THREAD_OPTION_SET, THREAD_OPTIONS, writeCollapsedProviders, @@ -365,6 +367,11 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const [v2Busy, setV2Busy] = useState(false); const [v2Note, setV2Note] = useState(""); const v2BusyRef = useRef(false); + // Freshness for /api/v2 reads: readSeq makes the latest issued GET the only one + // allowed to apply; writeSeq bumps on every PUT so a response read in flight + // across a completed write is discarded instead of reverting the fresher state. + const v2ReadSeqRef = useRef(0); + const v2WriteSeqRef = useRef(0); const [threadsCustom, setThreadsCustom] = useState(""); const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); @@ -487,11 +494,18 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const loadV2 = useCallback(async () => { // Never let a toggle in flight be clobbered by the poll (same single-flight rule as models). if (v2BusyRef.current) return; + const readSeq = ++v2ReadSeqRef.current; + const writeSeq = v2WriteSeqRef.current; + // Checked before every apply below: a write landing while this GET was in + // flight already adopted fresher state, so the stale response is dropped. + const fresh = () => readSeq === v2ReadSeqRef.current && writeSeq === v2WriteSeqRef.current; const bounded = createBoundedFetch(15_000); try { const r = await fetch(`${apiBase}/api/v2`, { signal: bounded.signal }); + if (!fresh()) return; if (!(r.headers.get("content-type") ?? "").includes("application/json")) { setV2(null); return; } const data = await readJsonIfOk(r); + if (!fresh()) return; if (!data || typeof data.enabled !== "boolean") { setV2(null); return; } setV2({ enabled: data.enabled, @@ -501,7 +515,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c keepNativeChatGptOnV1: data.keepNativeChatGptOnV1 === true, }); } catch { - setV2(null); // old server / network: hide the section instead of guessing + if (fresh()) setV2(null); // old server / network: hide the section instead of guessing } finally { bounded.clear(); setV2Loading(false); @@ -1095,6 +1109,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c if (!v2 || v2BusyRef.current) return; setV2Busy(true); v2BusyRef.current = true; + v2WriteSeqRef.current++; setV2Note(""); setStatus(""); try { @@ -1223,22 +1238,29 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c await putV2Setting({ keepNativeChatGptOnV1: next }); }; + // The API reports the limit in the active backend's units; the control edits the + // logical child count, so the V2 storage case (total incl. the root slot) converts. + const v2ThreadsChildren = v2?.maxConcurrentThreadsPerSession != null + ? threadLimitToChildCount(v2.maxConcurrentThreadsPerSession, v2.enabled) + : null; + const putV2Threads = async (value: number) => { // Same guards as the flag toggle: single-flight + server-side idempotence // (setMaxConcurrentThreads no-ops on equal value), so a re-selected current // value or a double click can never double-write config.toml. if (!v2 || v2BusyRef.current) return; if (!Number.isInteger(value) || value < 1) { publishFeedback(false, t("models.v2ThreadsInvalid")); return; } - if (v2.maxConcurrentThreadsPerSession === value) return; + if (v2ThreadsChildren === value) return; setV2Busy(true); v2BusyRef.current = true; + v2WriteSeqRef.current++; setV2Note(""); setStatus(""); try { const r = await fetch(`${apiBase}/api/v2`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ maxConcurrentThreadsPerSession: value }), + body: JSON.stringify({ maxConcurrentThreadsPerSession: childCountToThreadLimit(value, v2.enabled) }), }); try { const data = await readJsonOrThrow(r, t("models.saveFailed")); @@ -1270,7 +1292,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c }; const onSelectThreads = (raw: string) => { - if (raw === CUSTOM_OPTION) { setShowThreadsCustom(true); setThreadsCustom(String(v2?.maxConcurrentThreadsPerSession ?? "")); return; } + if (raw === CUSTOM_OPTION) { setShowThreadsCustom(true); setThreadsCustom(String(v2ThreadsChildren ?? "")); return; } setShowThreadsCustom(false); void putV2Threads(Number(raw)); }; @@ -2030,48 +2052,43 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c )} - {v2 && (v2.enabled || v2.agentsMaxThreadsConflict || v2Note) && ( + {v2 && (
- {v2.enabled && ( + {t("models.v2ThreadsLabel")} + ({ value: String(v), label: String(v) })), - { value: CUSTOM_OPTION, label: t("models.custom") }, - ]} - onChange={v => onSelectThreads(v)} + setThreadsCustom(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") void putV2Threads(Number(threadsCustom.replace(/[_,\s]/g, ""))); }} disabled={v2Busy} - label={t("models.v2ThreadsLabel")} + aria-label={t("models.v2ThreadsLabel")} /> - {showThreadsCustom && ( - <> - setThreadsCustom(e.target.value)} - onKeyDown={e => { if (e.key === "Enter") void putV2Threads(Number(threadsCustom.replace(/[_,\s]/g, ""))); }} - disabled={v2Busy} - aria-label={t("models.v2ThreadsLabel")} - /> - - - )} + )} {v2.enabled && v2.agentsMaxThreadsConflict && ( @@ -2550,7 +2567,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c
} {controlsBlock} diff --git a/gui/src/pages/models-settings-summary.ts b/gui/src/pages/models-settings-summary.ts index 1994b6a88ce..2e6d78f3856 100644 --- a/gui/src/pages/models-settings-summary.ts +++ b/gui/src/pages/models-settings-summary.ts @@ -38,7 +38,7 @@ export function writeSettingsOpen(open: boolean, storage?: StorageLike): void { export interface ModelsSettingsState { multiAgentMode?: "v1" | "default" | "v2"; - /** Explicit per-session thread cap; null/undefined means Codex's default. */ + /** Configured sub-agent limit as a logical child count; null/undefined means the runtime default. */ v2Threads?: number | null; keepNativeOnV1?: boolean; shadowEnabled: boolean; @@ -70,7 +70,7 @@ export function modelsSettingsSummary(t: TFn, state: ModelsSettingsState): Setti if (state.multiAgentMode) { items.push({ id: "subagent", label: t("models.v2Label"), value: t(`models.v2Mode_${state.multiAgentMode}` as TKey) }); } - if (state.multiAgentMode === "v2" && typeof state.v2Threads === "number") { + if (typeof state.v2Threads === "number") { items.push({ id: "threads", label: t("models.v2ThreadsLabel"), value: String(state.v2Threads) }); } if (state.multiAgentMode === "v2" && state.keepNativeOnV1) { diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 8dc8c4c73aa..c3896f3a340 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -187,6 +187,24 @@ export const NATIVE_CAP_OPTION_SET = new Set(NATIVE_CAP_OPTIONS); export const CUSTOM_OPTION = "custom"; export const THREAD_OPTIONS = [4, 8, 16, 32, 64, 128, 256, 500, 1000]; export const THREAD_OPTION_SET = new Set(THREAD_OPTIONS); + +/** + * The /api/v2 maxConcurrentThreadsPerSession field is expressed in the ACTIVE backend's + * units: the [agents] keys (v1/base/hybrid, enabled:false) count child agents, while + * the V2 storage counts the root agent's own slot too (total = children + 1). The + * control always presents the logical child count, so only the V2-total case translates + * across the root slot — the same boundary v2TotalLimitToV1ChildLimit / + * v1ChildLimitToV2TotalLimit keep in src/codex/features.ts. A stored V2 total of 1 + * (root only) has no representable child count >= 1 and clamps to 1, matching upstream. + */ +export function threadLimitToChildCount(limit: number, v2Enabled: boolean): number { + return v2Enabled ? Math.max(1, limit - 1) : limit; +} + +export function childCountToThreadLimit(children: number, v2Enabled: boolean): number { + return v2Enabled ? children + 1 : children; +} + export const PAGE = 60; // rows rendered per provider before a "show more" export const COLLAPSED_KEY_V2 = "ocx-models-collapsed:v2"; diff --git a/gui/tests/models-settings-panel.test.tsx b/gui/tests/models-settings-panel.test.tsx index 3c66f9ac386..4c58bfe6f4d 100644 --- a/gui/tests/models-settings-panel.test.tsx +++ b/gui/tests/models-settings-panel.test.tsx @@ -79,12 +79,13 @@ test("summary lists sub-agent, shadow, window and order in that order; new-model expect(off.at(-1)?.id).toBe("new-models"); // While the v2 settings are still loading there is no truthful mode to show. expect(modelsSettingsSummary(t, { ...base, multiAgentMode: undefined })[0]?.id).toBe("shadow"); - // Non-default folded controls surface too; the thread cap and keep-native only mean anything on v2. + // Non-default folded controls surface too; the limit shows whenever a value came back, + // while keep-native only means anything on v2. const v2 = modelsSettingsSummary(t, { ...base, multiAgentMode: "v2", v2Threads: 8, keepNativeOnV1: true, aliasesOn: true }); expect(v2.map(item => item.id)).toEqual(["subagent", "threads", "keep-native", "shadow", "window", "order", "aliases"]); expect(v2.find(item => item.id === "threads")?.value).toBe("8"); expect(modelsSettingsSummary(t, { ...base, v2Threads: 8, keepNativeOnV1: true }).map(item => item.id)) - .toEqual(["subagent", "shadow", "window", "order"]); + .toEqual(["subagent", "threads", "shadow", "window", "order"]); }); test("starts folded, and a user toggle is remembered under its own key", () => { diff --git a/gui/tests/models-subagent-limit.test.tsx b/gui/tests/models-subagent-limit.test.tsx new file mode 100644 index 00000000000..bc0b46f72c7 --- /dev/null +++ b/gui/tests/models-subagent-limit.test.tsx @@ -0,0 +1,330 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Models from "../src/pages/Models"; + +/** + * The sub-agent limit selector edits the logical child count in every multi-agent + * mode (v1, base, v2, hybrid). /api/v2 reports the limit in the ACTIVE backends + * units — [agents] counts children, V2 storage counts the root slot too — so the + * rendered number subtracts one only while the global flag is on, and PUTs add it + * back. Previously the control rendered only while v2.enabled was true. + */ + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "setInterval", "clearInterval", "fetch", +] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let v2Body: Record; +let v2Puts: Array>; +let pollTicks: Array<() => void>; +let deferV2Gets: boolean; +let pendingV2Gets: Array<{ resolve: (r: Response) => void; reject: (e: unknown) => void }>; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; + v2Puts = []; + pollTicks = []; + deferV2Gets = false; + pendingV2Gets = []; + v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; + testWindow = new Window({ url: "http://localhost/" }); + // The v2 status poll runs on window.setInterval (visibility-poll.ts); capturing the + // tick lets a test hold a GET in flight across a write instead of waiting 10s. + testWindow.setInterval = ((fn: () => void) => { pollTicks.push(fn); return pollTicks.length; }) as typeof setInterval; + testWindow.clearInterval = (() => {}) as typeof clearInterval; + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ + models: [ + { provider: "anthropic", id: "claude-sonnet-5", namespaced: "anthropic/claude-sonnet-5", disabled: false }, + ], + providers: [{ name: "anthropic", liveModels: true, models: ["claude-sonnet-5"] }], + selectedModels: {}, + disabled: [], + contextCaps: {}, + contextCapValue: 350_000, + })); + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/v2")) { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as Record; + v2Puts.push(body); + v2Body = { ...v2Body, ...body }; + // Selecting v1 unconditionally drops the native flag (agent-settings-routes modeFlag). + if (body.multiAgentMode === "v1") v2Body = { ...v2Body, enabled: false }; + return Response.json(v2Body); + } + if (deferV2Gets) { + return new Promise((resolve, reject) => pendingV2Gets.push({ resolve, reject })); + } + return Response.json(v2Body); + } + if (url.endsWith("/api/subagent-models") && init?.method !== "PUT") { + return Response.json({ pickerAvailable: ["anthropic/claude-sonnet-5"], pickerOrder: [], pickerOrderMode: null }); + } + if (url.endsWith("/api/models")) { + return Response.json([ + { provider: "anthropic", id: "claude-sonnet-5", namespaced: "anthropic/claude-sonnet-5", disabled: false }, + ]); + } + if (url.endsWith("/api/providers")) return Response.json([{ name: "anthropic", liveModels: true, models: ["claude-sonnet-5"] }]); + if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + return new Response(null, { status: 404 }); + }) as typeof fetch; + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + clearClientResourceStoresForTests(); + 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); + } + } +}); + +async function mount() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); +} + +const limitSelect = () => + container.querySelector('button[role="combobox"][aria-label="Sub-agent limit"]'); +const options = () => + [...testWindow.document.body.querySelectorAll('[role="option"]')]; +async function pickOption(label: string) { + await act(async () => { limitSelect()!.click(); }); + const option = options().find(node => node.textContent === label); + expect(option, "option " + label).toBeDefined(); + await act(async () => { option!.click(); }); +} +const lastPut = () => v2Puts.at(-1); + +const subagentRadios = () => [ + ...container.querySelectorAll( + '[role="radiogroup"][aria-label="Sub-agent"] button[role="radio"]'), +]; +const checkedMode = () => subagentRadios().find(node => node.getAttribute("aria-checked") === "true"); +async function clickV1Mode() { + const radio = subagentRadios().find(node => node.textContent === "v1"); + expect(radio).toBeDefined(); + await act(async () => { radio!.click(); }); +} +async function firePollTick() { + await act(async () => { pollTicks.forEach(fn => fn()); }); +} + +test("v1 mode exposes an editable child-count selector and PUTs the same units", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; + await mount(); + + const select = limitSelect(); + expect(select).not.toBeNull(); + expect(select!.disabled).toBe(false); + expect(select!.textContent).toContain("16"); + + await pickOption("32"); + expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 32 }); +}); + +test("hybrid v2 with the keep-native pin (enabled:false) stays editable in child units", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: false, multiAgentMode: "v2", keepNativeChatGptOnV1: true, maxConcurrentThreadsPerSession: 16 }; + await mount(); + + const select = limitSelect(); + expect(select).not.toBeNull(); + expect(select!.textContent).toContain("16"); + + await pickOption("64"); + expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 64 }); +}); + +test("full v2 presents children (stored total minus the root slot) and PUTs total back", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 17 }; + await mount(); + + const select = limitSelect(); + expect(select!.textContent).toContain("16"); + expect(select!.textContent).not.toContain("17"); + + await pickOption("32"); + expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 33 }); +}); + +test("the folded settings summary names the returned child limit in any mode", async () => { + // Panel stays folded (no settingsOpen key): the summary line must still carry the value. + v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; + await mount(); + + const chip = container.querySelector('.models-settings-kv[data-item="threads"]'); + expect(chip).not.toBeNull(); + expect(chip!.textContent).toContain("Sub-agent limit"); + expect(chip!.textContent).toContain("16"); +}); + +test("an unset limit shows the runtime-default placeholder without a hardcoded number", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: false, multiAgentMode: "v1" }; + await mount(); + + const select = limitSelect(); + expect(select).not.toBeNull(); + expect(select!.textContent).toContain("default"); + expect(select!.textContent).not.toContain("(4)"); +}); + +test("the custom entry path still PUTs through the shared writer", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; + await mount(); + + await pickOption("Custom…"); + const input = container.querySelector('input[aria-label="Sub-agent limit"]')!; + expect(input).not.toBeNull(); + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, "24"); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true }) as unknown as Event); + input.dispatchEvent(new testWindow.Event("change", { bubbles: true }) as unknown as Event); + }); + const apply = [...container.querySelectorAll(".models-v2-detail-row button")] + .find(node => node.textContent === "Apply")!; + await act(async () => { apply.click(); }); + expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 24 }); +}); + +/** + * FreshLuna P2: a poll GET issued before a write can resolve AFTER the write's + * response was adopted. Without a freshness check the stale body reverts + * v2.enabled, and the next limit save converts in the wrong backend's units. + */ +test("a poll response older than a completed mode write is discarded", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 17 }; + await mount(); + expect(limitSelect()!.textContent).toContain("16"); + + // The 10s poll issues a GET that stays in flight across the mode write below. + deferV2Gets = true; + await firePollTick(); + expect(pendingV2Gets).toHaveLength(1); + + // The write lands first and its response is the fresher state: v1 backend. + await clickV1Mode(); + expect(checkedMode()!.textContent).toBe("v1"); + + // The stale snapshot (flag still on) arrives late and must not be applied. + const stale = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 17 }; + await act(async () => { + pendingV2Gets.shift()!.resolve(Response.json(stale)); + }); + expect(checkedMode()!.textContent).toBe("v1"); + + // Child units still — a reverted flag would silently send 33 here. + await pickOption("32"); + expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 32 }); +}); + +test("a stale poll resolving non-JSON after a write keeps the fresh state", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 17 }; + await mount(); + + deferV2Gets = true; + await firePollTick(); + expect(pendingV2Gets).toHaveLength(1); + + await clickV1Mode(); + await act(async () => { + pendingV2Gets.shift()!.resolve(new Response(null, { status: 404 })); + }); + + // Discarded, not applied as null: the row stays editable on the fresh v1 state. + const select = limitSelect(); + expect(select).not.toBeNull(); + expect(select!.textContent).toContain("17"); +}); + +test("a stale poll rejection after a write keeps the fresh state", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: true, multiAgentMode: "v2", maxConcurrentThreadsPerSession: 17 }; + await mount(); + + deferV2Gets = true; + await firePollTick(); + expect(pendingV2Gets).toHaveLength(1); + + await clickV1Mode(); + await act(async () => { + pendingV2Gets.shift()!.reject(new Error("late network failure")); + await Promise.resolve(); + }); + + expect(limitSelect()).not.toBeNull(); + expect(limitSelect()!.textContent).toContain("17"); +}); + +test("an older poll response never overrides a newer one", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; + await mount(); + + deferV2Gets = true; + await firePollTick(); + await firePollTick(); + expect(pendingV2Gets).toHaveLength(2); + + // Newer response first, then the superseded older one: last write wins is wrong here. + await act(async () => { + pendingV2Gets[1].resolve(Response.json({ enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 64 })); + }); + expect(limitSelect()!.textContent).toContain("64"); + await act(async () => { + pendingV2Gets[0].resolve(Response.json({ enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 8 })); + }); + expect(limitSelect()!.textContent).toContain("64"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..6261d8ef2a4 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -685,6 +685,7 @@ "codex-transition-state.test.ts": "codex-integration", "codex-user-identity.test.ts": "codex-integration", "codex-v2-gate.test.ts": "codex-integration", + "codex-agent-thread-limit.test.ts": "codex-integration", "codex-warmup.test.ts": "codex-integration", "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", diff --git a/src/codex/features.ts b/src/codex/features.ts index 9e64057987d..2fe70e575c3 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -44,6 +44,10 @@ import { resolveCodexHomeDir } from "./home"; import { resolveAndPersistCodexRuntime } from "./runtime"; import { decodeOverlayState } from "./shim-state-file"; import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy"; +import { rootAssignmentKey, sourceText, splitSourceLines } from "./toml-source-lines"; + +const AGENTS_THREAD_LIMIT_KEY = "max_concurrent_threads_per_session"; +const LEGACY_AGENTS_THREAD_LIMIT_KEY = "max_threads"; /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */ export const DEFAULT_MODE_REQUEST_USER_INPUT_FEATURE_KEY = "default_mode_request_user_input"; @@ -328,36 +332,31 @@ export function isDefaultModeRequestUserInputEnabled(configPath?: string): boole } /** - * TRUE when config.toml still carries `[agents] max_threads` — codex-rs REFUSES to - * boot with that key while multi_agent_v2 is enabled ("agents.max_threads cannot be - * set when features.multi_agent_v2 is enabled", core/src/config/mod.rs:1421). The - * `ocx v2 on` flow warns about it instead of editing config itself. + * Whether either spelling of the [agents] limit is present, including invalid + * values. Keep the exported compatibility name; migrations must remove both. */ export function hasAgentsMaxThreads(configPath?: string): boolean { const content = readConfigText(configPath); if (content === null) return false; const parsed = parsedTomlTable(content, "agents"); - if (parsed !== null) return Object.hasOwn(parsed, "max_threads"); - const agents = tomlTableBody(content, "agents"); - if (agents === null) return false; - return /^\s*max_threads\s*=/m.test(agents); + if (parsed !== null) return Object.hasOwn(parsed, AGENTS_THREAD_LIMIT_KEY) || Object.hasOwn(parsed, LEGACY_AGENTS_THREAD_LIMIT_KEY); + return agentsThreadLimitEntries(content).entries.length > 0; } -/** Current legacy v1 `[agents] max_threads`, or null when absent/invalid. */ +/** Canonical [agents] limit, falling back to max_threads only when absent. */ export function getAgentsMaxThreads(configPath?: string): number | null { const content = readConfigText(configPath); if (content === null) return null; const parsed = parsedTomlTable(content, "agents"); if (parsed !== null) { - const value = parsed.max_threads; + const value = Object.hasOwn(parsed, AGENTS_THREAD_LIMIT_KEY) ? parsed[AGENTS_THREAD_LIMIT_KEY] : parsed[LEGACY_AGENTS_THREAD_LIMIT_KEY]; return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null; } - const agents = tomlTableBody(content, "agents"); - if (agents === null) return null; - const m = agents.match(/^\s*max_threads\s*=\s*(\d+)\s*(?:#.*)?$/m); - if (!m) return null; - const value = Number(m[1]); - return Number.isInteger(value) && value >= 1 ? value : null; + // Unrelated valid 64-bit integers can make Bun reject the whole document. + // Decode the same isolated structural assignment the editor uses, including + // quoted keys and non-decimal integers, rather than treating unreadable as unset. + const entries = agentsThreadLimitEntries(content).entries; + return (entries.find(entry => entry.key === AGENTS_THREAD_LIMIT_KEY) ?? entries[0])?.value ?? null; } /** @@ -1310,33 +1309,59 @@ function editAgentsMaxThreads(value: number | null, configPath?: string, migrate const path = configPath ?? activeCodexConfigPath(); return editCodexConfigToml(path, (content): TomlEditOutcome => { const eol = dominantEol(content); - const lines = content.split(/\r?\n/); - const headerIdx = lines.findIndex(l => /^\s*\[agents\]\s*(?:#.*)?$/.test(l)); + const { lines, headerIdx, entries } = agentsThreadLimitEntries(content); if (headerIdx === -1) { if (value === null) return { next: content }; - const separator = lines.length > 0 && lines[lines.length - 1] !== "" ? [""] : []; - lines.push(...separator, "[agents]", `max_threads = ${value}${migratedComment ?? ""}`); - return { next: applyEol(lines.join("\n"), eol) }; + const separator = content.length > 0 && !content.endsWith("\n") ? eol : ""; + return { next: `${content}${separator}${eol}[agents]${eol}${AGENTS_THREAD_LIMIT_KEY} = ${value}${migratedComment ?? ""}${eol}` }; } - let end = lines.length; - for (let i = headerIdx + 1; i < lines.length; i++) { - if (/^\s*\[/.test(lines[i])) { end = i; break; } + const selected = entries.find(entry => entry.key === AGENTS_THREAD_LIMIT_KEY) ?? entries[0]; + if (entries.length === 1 && selected?.key === AGENTS_THREAD_LIMIT_KEY && selected.value === value + && value !== null && mergeTrailingComments(selected.comment, migratedComment) === (selected.comment ?? "")) return { next: content }; + let comment = selected?.comment; + for (const entry of entries) comment = mergeTrailingComments(comment, entry.comment); + comment = mergeTrailingComments(comment, migratedComment); + for (const entry of [...entries].reverse()) { + if (value !== null && entry === selected) { + const line = lines[entry.index]!; + line.text = `${line.text.match(/^\s*/)?.[0] ?? ""}${AGENTS_THREAD_LIMIT_KEY} = ${value}${comment}`; + } else lines.splice(entry.index, 1); } - const keyRe = /^(\s*)max_threads\s*=\s*(\d+)(\s*#.*)?$/; - for (let i = headerIdx + 1; i < end; i++) { - const m = lines[i].match(keyRe); - if (!m) continue; - if (value === null) lines.splice(i, 1); - else if (Number(m[2]) === value && (!migratedComment || migratedComment === m[3])) return { next: content }; - else lines[i] = `${m[1]}max_threads = ${value}${mergeTrailingComments(m[3], migratedComment)}`; - return { next: applyEol(lines.join("\n"), eol) }; + if (value !== null && !selected) { + if (!lines[headerIdx]!.eol) lines[headerIdx]!.eol = eol; + lines.splice(headerIdx + 1, 0, { text: `${AGENTS_THREAD_LIMIT_KEY} = ${value}${comment}`, eol, structural: true }); } - if (value === null) return { next: content }; - lines.splice(headerIdx + 1, 0, `max_threads = ${value}${migratedComment ?? ""}`); - return { next: applyEol(lines.join("\n"), eol) }; + return { next: sourceText(lines) }; }); } +/** Only structural assignments in the supported agents table are editable. */ +function agentsThreadLimitEntries(content: string) { + const lines = splitSourceLines(content); + const headerIdx = lines.findIndex(line => line.structural && /^\s*\[agents\]\s*(?:#.*)?$/.test(line.text)); + const entries: Array<{ index: number; key: string; value: number | null; comment?: string; editable: boolean }> = []; + if (headerIdx !== -1) { + for (let index = headerIdx + 1; index < lines.length; index++) { + const line = lines[index]!; + if (!line.structural) continue; + if (/^\s*\[/.test(line.text)) break; + const key = rootAssignmentKey(line.text); + if (key !== AGENTS_THREAD_LIMIT_KEY && key !== LEGACY_AGENTS_THREAD_LIMIT_KEY) continue; + const assignment = findTomlAssignment(line.text, key); + const editable = assignment !== null && (index + 1 === lines.length || lines[index + 1]!.structural); + let value: unknown; + try { value = (Bun.TOML.parse(line.text) as Record)[key]; } catch { + // Match the reader's large-integer fallback without widening edit syntax. + const integer = assignment && /^\d+$/.exec(line.text.slice(assignment.valueStart, assignment.valueEnd).trim()); + if (integer) value = Number(integer[0]); + } + entries.push({ index, key, editable, value: typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null, + comment: assignment ? line.text.slice(assignment.valueEnd).match(/\s*#.*$/)?.[0] : undefined }); + } + } + return { lines, headerIdx, entries }; +} + function removeMaxConcurrentThreads(configPath?: string): ConfigEditResult { const path = configPath ?? activeCodexConfigPath(); return editCodexConfigToml(path, (content): TomlEditOutcome => { @@ -1439,7 +1464,9 @@ function discoverStoredThreadLimit(configPath?: string): { value: number; units: } function activeThreadComment(content: string, v2Enabled: boolean): string | undefined { - const legacy = tomlTableBody(content, "agents")?.match(/^\s*max_threads\s*=\s*\d+(\s*#.*)$/m)?.[1]; + const entries = agentsThreadLimitEntries(content).entries; + let legacy = (entries.find(entry => entry.key === AGENTS_THREAD_LIMIT_KEY) ?? entries[0])?.comment; + for (const entry of entries) legacy = mergeTrailingComments(legacy, entry.comment) || undefined; const dedicated = tomlTableBody(content, "features.multi_agent_v2") ?.match(/^\s*max_concurrent_threads_per_session\s*=\s*\d+(\s*#.*)$/m)?.[1]; const features = tomlTableBody(content, "features"); @@ -1503,8 +1530,8 @@ export type MultiAgentV2TransitionResult = | { ok: true; changed: boolean; threadLimit: number | null } | { ok: false; error: string; retryable?: boolean }; -function transitionConfigError(content: string): string | null { - if (/^\s*(?:features\.multi_agent_v2(?:\.[A-Za-z0-9_]+)?|agents\.max_threads)\s*=/m.test(content)) { +function transitionConfigError(content: string, explicitThreadLimit: boolean): string | null { + if (/^\s*(?:features\.multi_agent_v2(?:\.[A-Za-z0-9_]+)?|agents\.(?:max_threads|max_concurrent_threads_per_session))\s*=/m.test(content)) { return "dotted multi-agent config keys are not supported for automatic migration"; } const dedicatedTables = content.match(/^\s*\[features\.multi_agent_v2\]\s*(?:#.*)?$/gm) ?? []; @@ -1521,9 +1548,18 @@ function transitionConfigError(content: string): string | null { if (features && /^\s*multi_agent_v2\.(?:enabled|max_concurrent_threads_per_session)\s*=/m.test(features)) { return "dotted multi_agent_v2 fields are not supported for automatic migration"; } - const agents = tomlTableBody(content, "agents"); - if ((agents?.match(/^\s*max_threads\s*=/gm) ?? []).length > 1) { - return "duplicate agents.max_threads definitions cannot be migrated safely"; + const entries = agentsThreadLimitEntries(content).entries; + const parsedAgents = parsedTomlTable(content, "agents"); + for (const key of [AGENTS_THREAD_LIMIT_KEY, LEGACY_AGENTS_THREAD_LIMIT_KEY]) { + const matches = entries.filter(entry => entry.key === key); + if (matches.length > 1) return `duplicate agents.${key} definitions cannot be migrated safely`; + if (parsedAgents && Object.hasOwn(parsedAgents, key) && matches.length === 0) { + return "unsupported agents thread-limit storage cannot be migrated safely"; + } + } + if (entries.some(entry => !entry.editable)) return "unsupported agents thread-limit value cannot be migrated safely"; + if (!explicitThreadLimit && (entries.some(entry => entry.value === null) || (entries.length === 2 && entries[0]!.value !== entries[1]!.value))) { + return "conflicting or invalid agents thread-limit aliases require an explicit thread limit"; } const dedicated = tomlTableBody(content, "features.multi_agent_v2"); if ((dedicated?.match(/^\s*max_concurrent_threads_per_session\s*=/gm) ?? []).length > 1) { @@ -1542,15 +1578,15 @@ export function transitionMultiAgentV2( toggleFeature: (enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void, options: { configPath?: string; threadLimit?: number; heldConfigWriteLock?: LockHandle } = {}, ): MultiAgentV2TransitionResult { - if (options.threadLimit !== undefined && (!Number.isInteger(options.threadLimit) || options.threadLimit < 1)) { - return { ok: false, error: "thread limit must be an integer >= 1" }; + if (options.threadLimit !== undefined && (!Number.isSafeInteger(options.threadLimit) || options.threadLimit < 1)) { + return { ok: false, error: "thread limit must be a safe integer >= 1" }; } const path = options.configPath ?? activeCodexConfigPath(); if (codexHomeIsAbsent(dirname(path))) return { ok: false, error: `config.toml not readable at ${path}` }; const runTransition = (held: LockHandle): MultiAgentV2TransitionResult => { const original = readConfigText(path); if (original === null) return { ok: false, error: `config.toml not readable at ${path}` }; - const preflightError = transitionConfigError(original); + const preflightError = transitionConfigError(original, options.threadLimit !== undefined); if (preflightError) return { ok: false, error: preflightError }; const beforeEnabled = isMultiAgentV2Enabled(path); // A caller-supplied limit is already in the DESTINATION backend's units and is @@ -1573,6 +1609,9 @@ export function transitionMultiAgentV2( ? v1ChildLimitToV2TotalLimit(discovered.value) : v2TotalLimitToV1ChildLimit(discovered.value); } + if (threadLimit !== null && !Number.isSafeInteger(threadLimit)) { + return { ok: false, error: "stored thread limit cannot be written safely; supply an explicit safe integer limit" }; + } const migratedComment = activeThreadComment(original, beforeEnabled); try { return withConfigWriteRecovery(path, held, () => { @@ -1606,7 +1645,8 @@ export function transitionMultiAgentV2( return editAgentsMaxThreads(threadLimit, tempPath, migratedComment); }); if (!target.ok) throw new Error(target.error); - if (getMaxConcurrentThreads(path) !== null || getAgentsMaxThreads(path) !== threadLimit) throw new Error("v1 thread-limit migration postcondition failed"); + const agents = parsedTomlTable(readConfigText(path) ?? "", "agents"); + if (getMaxConcurrentThreads(path) !== null || getAgentsMaxThreads(path) !== threadLimit || (agents && Object.hasOwn(agents, LEGACY_AGENTS_THREAD_LIMIT_KEY))) throw new Error("v1 thread-limit migration postcondition failed"); } return { ok: true, changed: readConfigText(path) !== original, threadLimit }; }); diff --git a/structure/INDEX.md b/structure/INDEX.md index 0942d50a14a..990279bfa2b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -31,6 +31,7 @@ Persisted config, the Codex home it writes into, and the model catalog it publis | [`catalog-vendor-context.md`](catalog-vendor-context.md) | Bundled model-ID window fallback, ambiguity handling, and inferred-capacity provenance. | | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | Persisted effort-clamp observation identity and idempotent sync notices. | | [`subagents.md`](subagents.md) | Multi-agent surface mode and subagent roster ordering. | +| [`subagent-capacity.md`](subagent-capacity.md) | Surface mode, hybrid global flag, concurrent child limits and canonical Codex setting ownership. | | [`advisor.md`](advisor.md) | The OpenCodex-owned expert consultation sidecar: synthetic advisor tool, preflight policy, loopback consultation through the routing authority, and the optional-subsystem seam. | | [`config-proxy.md`](config-proxy.md) | Global proxy activation, start flags, and credential-safe CLI output. | @@ -151,7 +152,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | -| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/codex/catalog/effort.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07ed..c9f3c5a8561 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -281,7 +281,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). 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. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | 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`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | +| 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`), the `keepNativeChatGptOnV1` hybrid pin, and `maxThreads`. That API value counts children with `enabled: false` and includes the primary thread with `enabled: true`. The Models page's Model settings panel always displays children and exposes the limit even when the global flag is disabled; see [concurrent child limit](subagents.md#concurrent-child-limit). Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | Reached through the Usage & Logs row’s section switcher (`/#logs`), with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. 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` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | diff --git a/structure/manifest.json b/structure/manifest.json index 5176735ae24..27c7ce37772 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -190,6 +190,15 @@ "src/server/" ] }, + { + "path": "subagent-capacity.md", + "tier": 2, + "title": "Subagent Surface And Capacity", + "scope": "Surface mode, hybrid global flag, concurrent child limits and canonical Codex setting ownership.", + "documents": [ + "src/codex/" + ] + }, { "path": "advisor.md", "tier": 2, diff --git a/structure/subagent-capacity.md b/structure/subagent-capacity.md new file mode 100644 index 00000000000..34a6be11f0e --- /dev/null +++ b/structure/subagent-capacity.md @@ -0,0 +1,75 @@ +# Subagent Surface And Capacity + +Surface and limit settings are independent of [subagent roster ordering](subagents.md). + +## Multi-agent surface mode (3-state) + +`OcxConfig.multiAgentMode` controls the `multi_agent_version` field stamped on catalog entries: + +| Mode | Behavior | +| --- | --- | +| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` ??overrides upstream pins (sol/terra included). | +| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null ??codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | +| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` ??overrides upstream pins (luna included). | + +The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and +`mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This +ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does +not clobber the forced value. + +A forced pass records each row's pre-override value once, in `opencodex_multi_agent_version_origin` +(a string pin, or null for none); repeated forced passes never replace it. Returning to `"default"` +consumes the record. Pristine baseline and native pins still win; the record only decides a native +row the baseline predates, which previously kept the forced stamp because an absent baseline entry +cannot tell a stale forced value from a genuine pin (issue 5636). Rows written before the record +existed keep that non-destructive read. + +`getDefaultConfig()` (`src/config/proxy-env.ts`) writes `multiAgentMode: "v1"` explicitly, using the version +constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2 +native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in +`src/config/diagnostics.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored +document, because spreading the defaults underneath would repair an unrelated missing field +into a surface change its operator never made. +An absent key still means `"default"`, because selecting base deletes the key ??absence cannot be +read as "never configured". An install that predates that change is therefore not rewritten; it is +asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and +the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and +only the operator answering the dashboard notice writes that version. + +CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` +with `multiAgentMode` field. + +The `multi_agent_v2` feature flag and the logical maximum thread count are separate from +`multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while +the flag and thread count decide what the native runtime allows. +Because the global feature has precedence over catalog pins, Codex config injection reconciles it +to disabled whenever persisted OpenCodex mode explicitly selects v1. This includes a fresh install +on a Codex home that had previously enabled v2; external-provider ownership and read-only injection +preflight still prohibit that write. The transition runs inside the same write lock and preimage as +the rest of injection, so a later refusal rolls the flag back with the files. + +`keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global +`multi_agent_v2` override because codex-rs resolves that override before a model row's explicit +`multi_agent_version`. Native ChatGPT rows then select v1 from the catalog and routed rows select +v2. An explicit attempt to enable the global flag while the hybrid pin is active is rejected. + +### Concurrent child limit + +The Models page's Model settings panel exposes one subagent limit control in v1, base and v2, +including hybrid mode with the global `multi_agent_v2` flag disabled. Its displayed value counts +children only. Surface selection and capacity are independent; raising the cap neither spawns +children automatically nor guarantees faster execution. + +`GET/PUT /api/v2` retains its existing `maxThreads` units: with `enabled: false`, N means N +children; with `enabled: true`, N+1 includes the primary thread. The GUI subtracts one for display +and adds one on save only when that global flag is enabled. Catalog-selected v2 in hybrid mode +does not change those API units. An unset limit leaves the default to the native runtime. +New sessions consume limit changes; a long-running App host may need a normal restart. + +`src/codex/features.ts` reads and writes the canonical `[agents].max_concurrent_threads_per_session` +child limit, falling back to the legacy `max_threads` alias. Equal-valued aliases normalize to +the canonical key on write; conflicting aliases refuse a write unless an explicit replacement +limit resolves them. Unsupported or unsafe writes refuse before mutation. With the global v2 +flag disabled, a limit edit updates the canonical child count and removes a stale feature-level +v2 limit without enabling that flag. These rules establish configuration consistency; they do +not attest the effective capacity of a running native or routed session. diff --git a/structure/subagents.md b/structure/subagents.md index 23233beb0a2..b965522e299 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -62,54 +62,11 @@ resolver enforces source identity and expiry before those credentials reach rout ## Multi-agent surface mode (3-state) -`OcxConfig.multiAgentMode` controls the `multi_agent_version` field stamped on catalog entries: - -| Mode | Behavior | -| --- | --- | -| `"v1"` | Force ALL entries to `multi_agent_version = "v1"` ??overrides upstream pins (sol/terra included). | -| `"default"` | Respect upstream model pins (sol/terra=v2, luna=v1, others=null ??codex feature flag decides). On sync, stale forced values are cleared and upstream pins restored. | -| `"v2"` | Force ALL entries to `multi_agent_version = "v2"` ??overrides upstream pins (luna included). | - -The override is applied as a final pass in both `buildCatalogEntries` (live `/v1/models` path) and -`mergeCatalogEntriesForSync` (on-disk sync), AFTER all normalization and visibility processing. This -ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does -not clobber the forced value. - -A forced pass records each row's pre-override value once, in `opencodex_multi_agent_version_origin` -(a string pin, or null for none); repeated forced passes never replace it. Returning to `"default"` -consumes the record. Pristine baseline and native pins still win; the record only decides a native -row the baseline predates, which previously kept the forced stamp because an absent baseline entry -cannot tell a stale forced value from a genuine pin (issue 5636). Rows written before the record -existed keep that non-destructive read. - -`getDefaultConfig()` (`src/config/proxy-env.ts`) writes `multiAgentMode: "v1"` explicitly, using the version -constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2 -native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in -`src/config/diagnostics.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored -document, because spreading the defaults underneath would repair an unrelated missing field -into a surface change its operator never made. -An absent key still means `"default"`, because selecting base deletes the key ??absence cannot be -read as "never configured". An install that predates that change is therefore not rewritten; it is -asked once. `multiAgentSurfaceAdvisoryRequired()` is true while the resolved mode is not v1 and -the stored `multiAgentSurfaceAdvisoryVersion` is below `MULTI_AGENT_SURFACE_ADVISORY_VERSION`, and -only the operator answering the dashboard notice writes that version. - -CLI: `ocx v2 mode v1|default|v2`. GUI: segmented control on the Models page. API: `GET/PUT /api/v2` -with `multiAgentMode` field. - -The `multi_agent_v2` feature flag and the logical maximum thread count are separate from -`multiAgentMode` (`src/codex/features.ts`): the mode decides which surface Codex advertises, while -the flag and thread count decide what the native runtime allows. -Because the global feature has precedence over catalog pins, Codex config injection reconciles it -to disabled whenever persisted OpenCodex mode explicitly selects v1. This includes a fresh install -on a Codex home that had previously enabled v2; external-provider ownership and read-only injection -preflight still prohibit that write. The transition runs inside the same write lock and preimage as -the rest of injection, so a later refusal rolls the flag back with the files. - -`keepNativeChatGptOnV1` makes mode `v2` a catalog-driven hybrid: OpenCodex disables the global -`multi_agent_v2` override because codex-rs resolves that override before a model row's explicit -`multi_agent_version`. Native ChatGPT rows then select v1 from the catalog and routed rows select -v2. An explicit attempt to enable the global flag while the hybrid pin is active is rejected. +See [surface selection and capacity](subagent-capacity.md#multi-agent-surface-mode-3-state) for the mode, global flag and hybrid configuration contract. + +### Concurrent child limit + +See [concurrent child limit](subagent-capacity.md#concurrent-child-limit) for GUI/API units and canonical Codex setting ownership. ### What the five-model `spawn_agent` window is, and how V1 differs from V2 @@ -128,7 +85,7 @@ Three different numbers, often conflated: | --- | --- | --- | | Models **advertised** as overrides | `min(5, picker-visible eligible rows)` | `multi_agents_spec.rs:785-790` | | Models **eligible** as targets | no numeric cap (only `"disabled"` is excluded, and only on V2) | `multi_agents_common.rs:36-42` | -| **Concurrent** subagents | V1 6 children (root excluded); V2 total 4 including root ??3 children | `config/mod.rs:211-212`, `:1497-1506` | +| **Concurrent** subagents | Configurable child limit, independent of the five-model window | [Concurrent child limit](#concurrent-child-limit) | **The cap is the same 5 on both surfaces, but the window's contents are not.** The eligibility filter runs *before* `.take(5)`, and it behaves differently per surface: on a V1 call diff --git a/tests/cli/cli-v2-runtime.test.ts b/tests/cli/cli-v2-runtime.test.ts index d8e516ba263..fa8789f0c0b 100644 --- a/tests/cli/cli-v2-runtime.test.ts +++ b/tests/cli/cli-v2-runtime.test.ts @@ -12,6 +12,7 @@ import { createTempHome, type TempHome } from "../helpers/temp-home"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; let home: TempHome, out: string[], errors: string[], path: string; +const ocxConfigPath = () => home.path(".opencodex", "config.json"); let logs: ReturnType, errs: ReturnType, network: ReturnType; const privateText = "synthetic-private-native-error"; const committed = { status: "committed", changed: false, degraded: false, notices: [] }; @@ -19,7 +20,7 @@ const syncOk = { status: "applied", ok: true, catalogExists: true, catalogWritte added: 0, catalogPath: null, message: privateText, refreshOutcome: "committed" }; const readOutput = () => JSON.parse(out.join("\n")); function config(extra: Record = {}) { - writeFileSync(home.path("config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", multiAgentMode: "default", ...extra })); + writeFileSync(ocxConfigPath(), JSON.stringify({ providers: {}, defaultProvider: "openai", multiAgentMode: "default", ...extra })); } function native(enabled = false, threads = 12) { writeFileSync(path, `[features.multi_agent_v2]\nenabled = ${enabled}\nmax_concurrent_threads_per_session = ${threads}\n`); @@ -57,6 +58,11 @@ function transport(body: unknown = state({ ok: true, catalogRefresh: committed } } beforeEach(() => { home = createTempHome("ocx-cli-v2-runtime-"); mkdirSync(home.codexHome); + // Match real sibling homes. Hardening an OPENCODEX_HOME ancestor on Windows + // can propagate inherited ACL metadata into its child CODEX_HOME and change + // config.toml's ctime during the native writer's preimage read fence. + process.env.OPENCODEX_HOME = home.path(".opencodex"); + mkdirSync(process.env.OPENCODEX_HOME); path = join(home.codexHome, "config.toml"); config(); native(); const binary = home.path("synthetic-codex"); writeFileSync(binary, Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.from("multi_agent_mode_hint_text")])); @@ -80,11 +86,11 @@ describe("v2 preparse and literal hints", () => { ["mode-hint", "--json=true"], ["mode-hint", "--live=yes"], ["mode-hint", "before", "--", "after"], ["mode-hint", "--", " "], ["unknown"], ]; test.each(bad.map(args => [args] as const))("local malformed argv refuses before effects %j", async args => { - const before = readFileSync(path, "utf8"), cfg = readFileSync(home.path("config.json"), "utf8"); + const before = readFileSync(path, "utf8"), cfg = readFileSync(ocxConfigPath(), "utf8"); const local = localDeps(); expect(await cmdV2([...args], local.deps, async () => { throw new Error("No discovery"); })).toBe(1); expect(local.actions).toEqual([]); expect(local.syncPorts).toEqual([]); - expect(readFileSync(path, "utf8")).toBe(before); expect(readFileSync(home.path("config.json"), "utf8")).toBe(cfg); + expect(readFileSync(path, "utf8")).toBe(before); expect(readFileSync(ocxConfigPath(), "utf8")).toBe(cfg); expect(out).toEqual([]); }); test.each([["status"], ["on"], ["off"], ["threads", "5"], ["keep-native-v1", "on"], ["mode-hint", "hint"]].map(args => [args] as const))( @@ -122,7 +128,7 @@ describe("live V2 fixed management operations", () => { [["mode-hint", "--clear"], { multiAgentModeHintText: null }], [["mode-hint", " custom "], { multiAgentModeHintText: " custom " }], ]; test.each(cases)("maps %j and leaves local state untouched", async (args, body) => { - const before = readFileSync(path, "utf8"), cfg = readFileSync(home.path("config.json"), "utf8"), files = readdirSync(home.root); + const before = readFileSync(path, "utf8"), cfg = readFileSync(ocxConfigPath(), "utf8"), files = readdirSync(home.root); const live = transport(); expect(await cmdV2([...args, "--live", "--json"], { runtimeApi: live.runtimeApi, isEnabled: () => { throw new Error("No native read"); }, execFile: () => { throw new Error("No native process"); } })).toBe(0); @@ -131,7 +137,7 @@ describe("live V2 fixed management operations", () => { expect(live.calls[0]!.init.redirect).toBe("error"); expect(live.calls[0]!.init.method).toBe(body ? "PUT" : "GET"); expect(body ? JSON.parse(String(live.calls[0]!.init.body)) : live.calls[0]!.init.body).toEqual(body); - expect(readFileSync(path, "utf8")).toBe(before); expect(readFileSync(home.path("config.json"), "utf8")).toBe(cfg); + expect(readFileSync(path, "utf8")).toBe(before); expect(readFileSync(ocxConfigPath(), "utf8")).toBe(cfg); expect(readdirSync(home.root)).toEqual(files); expect(readOutput().target).toBe("live"); }); test.each([[404, 4], [409, 5], [400, 1], [502, 1], [503, 1], [401, 1]])("safe refusal HTTP %i exits %i", async (status, code) => { @@ -170,11 +176,20 @@ describe("local V2 state and synchronization graph", () => { }); test.each([["status"], ["off"], ["threads", "7"], ["mode-hint", "sample"]].map(args => [args] as const))( "no new synchronization for %j", async args => { - if (args[0] === "off") writeFileSync(path, "[features.multi_agent_v2]\nenabled = false\n\n[agents]\nmax_threads = 12\n"); + if (args[0] === "off") writeFileSync(path, "[features.multi_agent_v2]\nenabled = false\n\n[agents]\nmax_concurrent_threads_per_session = 12\n"); const local = localDeps(); expect(await cmdV2([...args, "--json"], local.deps, async () => { throw new Error("No port discovery"); })).toBe(0); expect(local.syncPorts).toEqual([]); expect(readOutput().sync.status).toBe("not-attempted"); }); + test("off normalizes a legacy-only limit and synchronizes the changed config", async () => { + writeFileSync(path, "[features.multi_agent_v2]\nenabled = false\n\n[agents]\nmax_threads = 12\n"); + const local = localDeps(); + expect(await cmdV2(["off", "--json"], local.deps)).toBe(0); + expect(local.actions).toEqual([]); + expect(local.syncPorts).toEqual([undefined]); + expect(readOutput()).toMatchObject({ ok: true, changed: true, state: { enabled: false, maxConcurrentThreadsPerSession: 12 } }); + expect(Bun.TOML.parse(readFileSync(path, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 12 }); + }); test.each([undefined, {}, { ok: true }, { ...syncOk, catalogExists: "true" }, { ...syncOk, refreshOutcome: "unknown" }])( "unknown injected sync is unverified, preserving landed state %j", async result => { const local = localDeps(null); local.deps.sync = async () => result; @@ -275,9 +290,10 @@ describe("live V2 real-handler refusal and incomplete convergence", () => { const { handleManagementAPI } = await import("../../src/server/management-api"); const { loadConfig } = await import("../../src/config"); const before = readFileSync(path, "utf8"); let requests = 0, toggles = 0; + const nativeResponses: unknown[] = []; const runtimeApi: RuntimeApiDeps = { baseUrl: "http://localhost", fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { requests++; const headers = new Headers(init?.headers); headers.set("Host", "localhost"); - return await handleManagementAPI(new Request(String(url), { ...init, headers }), new URL(String(url)), loadConfig(), { + const response = await handleManagementAPI(new Request(String(url), { ...init, headers }), new URL(String(url)), loadConfig(), { toggleCodexMultiAgentV2: enabled => { toggles++; if (failure === "native-failure") throw new Error(privateText); @@ -285,11 +301,13 @@ describe("live V2 real-handler refusal and incomplete convergence", () => { }, createManagementConvergeCodex: catalogConvergenceFactory(() => {}, { status: "failed", reason: "disk", phase: "commit", retryable: true, partialWrite: true }), }) ?? new Response(null, { status: 404 }); + nativeResponses.push({ status: response.status, body: (await response.clone().text()).replaceAll(privateText, "") }); + return response; }) as typeof fetch }; expect(await cmdV2(["on", "--live", "--json"], { runtimeApi, execFile: () => { throw new Error("Local fallback is forbidden"); } })).toBe(1); // Errors ride along so a refusal before the native toggle names its cause in CI. - expect({ requests, toggles, errors }).toMatchObject({ requests: 1, toggles: 1 }); + expect({ requests, toggles, errors, nativeResponses }).toMatchObject({ requests: 1, toggles: 1 }); if (failure === "native-failure") { expect(out).toEqual([]); expect(readFileSync(path, "utf8")).toBe(before); } else expect(readOutput()).toMatchObject({ enabled: true, catalogRefresh: { status: "failed", partialWrite: true } }); expect(out.join("\n") + errors.join("\n")).not.toContain(privateText); diff --git a/tests/codex-integration/codex-agent-thread-limit.test.ts b/tests/codex-integration/codex-agent-thread-limit.test.ts new file mode 100644 index 00000000000..23011ab33d2 --- /dev/null +++ b/tests/codex-integration/codex-agent-thread-limit.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { getAgentsMaxThreads, getLogicalMaxThreads, getMaxConcurrentThreads, hasAgentsMaxThreads, transitionMultiAgentV2 } from "../../src/codex/features"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + +function fixtureConfig(content: string): string { + const dir = mkdtempSync(join(tmpdir(), "ocx-canonical-limit-")); + const path = join(dir, "config.toml"); + writeFileSync(path, content); + return path; +} + +describe("canonical [agents] thread-limit compatibility", () => { + test("canonical-only and old-only configs expose the stored limit", () => { + for (const key of ["max_concurrent_threads_per_session", "max_threads"]) { + const path = fixtureConfig(`[agents]\n${key} = 16\n`); + expect(hasAgentsMaxThreads(path)).toBe(true); + expect(getAgentsMaxThreads(path)).toBe(16); + expect(getLogicalMaxThreads(path)).toBe(16); + } + const prose = fixtureConfig('[agents]\nhint = """\nmax_concurrent_threads_per_session = 99\n"""\n'); + expect(hasAgentsMaxThreads(prose)).toBe(false); + expect(getAgentsMaxThreads(prose)).toBe(null); + }); + + test("canonical presence takes precedence even when its value is invalid", () => { + const conflicting = fixtureConfig("[agents]\nmax_threads = 6\nmax_concurrent_threads_per_session = 16\n"); + expect(getAgentsMaxThreads(conflicting)).toBe(16); + expect(getLogicalMaxThreads(conflicting)).toBe(16); + const invalid = fixtureConfig("[agents]\nmax_concurrent_threads_per_session = 0\nmax_threads = 6\n"); + expect(hasAgentsMaxThreads(invalid)).toBe(true); + expect(getAgentsMaxThreads(invalid)).toBe(null); + }); + + test("V1 edits canonical16 to canonical6 without a misleading legacy6 alias", () => { + const path = fixtureConfig("[agents]\nmax_concurrent_threads_per_session = 16 # tuned\nmax_depth = 2\n"); + let toggles = 0; + expect(transitionMultiAgentV2(false, () => { toggles++; }, { configPath: path, threadLimit: 6 })) + .toEqual({ ok: true, changed: true, threadLimit: 6 }); + const written = readFileSync(path, "utf8"); + expect(Bun.TOML.parse(written).agents).toEqual({ max_concurrent_threads_per_session: 6, max_depth: 2 }); + expect(written).toContain("# tuned"); + expect(getLogicalMaxThreads(path)).toBe(6); + expect(toggles).toBe(0); + expect(transitionMultiAgentV2(false, () => { toggles++; }, { configPath: path, threadLimit: 6 })) + .toEqual({ ok: true, changed: false, threadLimit: 6 }); + }); + + test("old-only and identical aliases normalize once, preserving comments and CRLF", () => { + for (const old of ["max_threads = 6 # old\r\n", "max_threads = 6 # old\r\nmax_concurrent_threads_per_session = 6 # canonical\r\n"]) { + const path = fixtureConfig(`# keep\r\n[agents]\r\n${old}max_depth = 2\r\n`); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path })).toMatchObject({ ok: true, threadLimit: 6 }); + const written = readFileSync(path, "utf8"); + expect(Bun.TOML.parse(written).agents).toEqual({ max_concurrent_threads_per_session: 6, max_depth: 2 }); + expect(written).toContain("old"); + if (old.includes("# canonical")) expect(written).toContain("# canonical"); + expect(written.replaceAll("\r\n", "")).not.toContain("\n"); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path })).toMatchObject({ ok: true, changed: false }); + } + }); + + test("conflicting aliases require an explicit replacement before either transition", () => { + const original = "[agents]\nmax_concurrent_threads_per_session = 16\nmax_threads = 6\n"; + const path = fixtureConfig(original); + let toggles = 0; + for (const enabled of [false, true]) { + expect(transitionMultiAgentV2(enabled, () => { toggles++; }, { configPath: path })).toMatchObject({ ok: false }); + expect(readFileSync(path, "utf8")).toBe(original); + } + expect(toggles).toBe(0); + expect(transitionMultiAgentV2(false, () => { toggles++; }, { configPath: path, threadLimit: 8 })) + .toMatchObject({ ok: true, threadLimit: 8 }); + expect(Bun.TOML.parse(readFileSync(path, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 8 }); + }); + + test("canonical V1 to V2 round trip retains the existing root-included V2 contract", () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\n\n[agents]\nmax_concurrent_threads_per_session = 16 # tuned\n"); + const flip = (enabled: boolean) => writeFileSync(path, readFileSync(path, "utf8").replace(/^enabled = (?:true|false)$/m, `enabled = ${enabled}`)); + expect(transitionMultiAgentV2(true, flip, { configPath: path })).toMatchObject({ ok: true, threadLimit: 17 }); + expect(hasAgentsMaxThreads(path)).toBe(false); + expect(getMaxConcurrentThreads(path)).toBe(17); + expect(transitionMultiAgentV2(false, flip, { configPath: path })).toMatchObject({ ok: true, threadLimit: 16 }); + expect(Bun.TOML.parse(readFileSync(path, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 16 }); + expect(readFileSync(path, "utf8")).toContain("# tuned"); + }); + + test("canonical no-op preserves numeric formatting and whitespace; old trailing spaces remain valid", () => { + const original = "[agents]\n max_concurrent_threads_per_session=0x10 \n"; + const path = fixtureConfig(original); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path })).toEqual({ ok: true, changed: false, threadLimit: 16 }); + expect(readFileSync(path, "utf8")).toBe(original); + const old = fixtureConfig("[agents]\nmax_threads = 6 \n"); + expect(transitionMultiAgentV2(false, () => {}, { configPath: old })).toMatchObject({ ok: true, threadLimit: 6 }); + expect(Bun.TOML.parse(readFileSync(old, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 6 }); + }); + + test.each(["max_concurrent_threads_per_session", '"max_concurrent_threads_per_session"', "max_threads", '"max_threads"'])( + "unrelated valid 64-bit TOML integer cannot erase the structural limit %s", key => { + const original = `model_context_window = 9007199254740993\n[agents]\n${key} = 0x10 # tuned\n`; + const path = fixtureConfig(original); + expect(() => Bun.TOML.parse(original)).toThrow(); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path })).toMatchObject({ ok: true, threadLimit: 16 }); + expect(hasAgentsMaxThreads(path)).toBe(true); + expect(getAgentsMaxThreads(path)).toBe(16); + expect(getLogicalMaxThreads(path)).toBe(16); + expect(readFileSync(path, "utf8")).toContain("model_context_window = 9007199254740993"); + expect(readFileSync(path, "utf8")).toContain("# tuned"); + }, + ); + + test("quoted numeric keys normalize while multiline prose remains byte-identical", () => { + const prose = 'hint = """\n[pretend]\nmax_threads = 99\n"""\n'; + const path = fixtureConfig(`[agents]\n${prose}"max_concurrent_threads_per_session" = 16\n`); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path, threadLimit: 6 })).toMatchObject({ ok: true, threadLimit: 6 }); + expect(readFileSync(path, "utf8")).toContain(prose); + expect(Bun.TOML.parse(readFileSync(path, "utf8")).agents).toMatchObject({ max_concurrent_threads_per_session: 6 }); + }); + + test("unsupported or duplicate canonical storage refuses without false success", () => { + for (const original of [ + "agents.max_concurrent_threads_per_session = 16\n", + "agents = { max_concurrent_threads_per_session = 16 }\n", + "[agents]\nmax_concurrent_threads_per_session = 16\nmax_concurrent_threads_per_session = 6\n", + '[agents]\nmax_concurrent_threads_per_session = """\n16\n"""\n', + ]) { + const path = fixtureConfig(original); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path, threadLimit: 6 })).toMatchObject({ ok: false }); + expect(readFileSync(path, "utf8")).toBe(original); + } + }); + + test("unsafe limits cannot become scientific-notation writes reported as success", () => { + const original = "[agents]\nmax_concurrent_threads_per_session = 16\n"; + const path = fixtureConfig(original); + expect(transitionMultiAgentV2(false, () => {}, { configPath: path, threadLimit: 1e20 })).toMatchObject({ ok: false }); + expect(readFileSync(path, "utf8")).toBe(original); + const unsafe = "[agents]\nmax_threads = 100000000000000000000\n"; + const stored = fixtureConfig(unsafe); + expect(transitionMultiAgentV2(false, () => {}, { configPath: stored })).toMatchObject({ ok: false }); + expect(readFileSync(stored, "utf8")).toBe(unsafe); + }); +}); + +describe("canonical management API thread limits", () => { + test("hybrid GET and limit-only PUT use canonical agents storage without enabling global V2", async () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\nmax_concurrent_threads_per_session = 17\n\n[agents]\nmax_concurrent_threads_per_session = 16\n"); + const oldCodexHome = process.env.CODEX_HOME; + const oldOcxHome = process.env.OPENCODEX_HOME; + process.env.CODEX_HOME = dirname(path); + process.env.OPENCODEX_HOME = mkdtempSync(join(tmpdir(), "ocx-canonical-hybrid-")); + const config = { providers: [], multiAgentMode: "v2", keepNativeChatGptOnV1: true } as never; + let toggles = 0; + const deps = { toggleCodexMultiAgentV2: () => { toggles++; }, createManagementConvergeCodex: catalogConvergenceFactory() }; + try { + const get = new Request("http://localhost/api/v2", { headers: { Host: "localhost" } }); + expect(await (await handleManagementAPI(get, new URL(get.url), config, deps))?.json()) + .toMatchObject({ enabled: false, multiAgentMode: "v2", keepNativeChatGptOnV1: true, maxConcurrentThreadsPerSession: 16 }); + const put = new Request(get.url, { + method: "PUT", headers: { Host: "localhost", "content-type": "application/json" }, body: JSON.stringify({ maxConcurrentThreadsPerSession: 6 }), + }); + const response = await handleManagementAPI(put, new URL(put.url), config, deps); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ enabled: false, multiAgentMode: "v2", keepNativeChatGptOnV1: true, maxConcurrentThreadsPerSession: 6 }); + expect(Bun.TOML.parse(readFileSync(path, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 6 }); + expect(getMaxConcurrentThreads(path)).toBe(null); + expect(toggles).toBe(0); + const reread = await handleManagementAPI(get, new URL(get.url), config, deps); + expect(await reread?.json()).toMatchObject({ maxConcurrentThreadsPerSession: 6 }); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = oldCodexHome; + if (oldOcxHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldOcxHome; + } + }); + +}); diff --git a/tests/codex-integration/codex-v2-gate.test.ts b/tests/codex-integration/codex-v2-gate.test.ts index 89fa6a9582c..66f7525a45a 100644 --- a/tests/codex-integration/codex-v2-gate.test.ts +++ b/tests/codex-integration/codex-v2-gate.test.ts @@ -797,7 +797,7 @@ describe("thread-limit-preserving v1/v2 transition", () => { expect(transitionMultiAgentV2(true, flipTableFlag(path), { configPath: path }).ok).toBe(true); expect(readFileSync(path, "utf8")).toContain("max_concurrent_threads_per_session = 101 # tuned"); expect(transitionMultiAgentV2(false, flipTableFlag(path), { configPath: path }).ok).toBe(true); - expect(readFileSync(path, "utf8")).toContain("max_threads = 100 # tuned"); + expect(readFileSync(path, "utf8")).toContain("max_concurrent_threads_per_session = 100 # tuned"); }); test("same-state repair prefers active storage when duplicate values disagree", () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..3ca0799ac1e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -499,6 +499,7 @@ "codex-transition-state-first-use-regression.test.ts": "codex-integration", "codex-transition-state-race.test.ts": "codex-integration", "codex-transition-state.test.ts": "codex-integration", "codex-user-identity.test.ts": "codex-integration", "codex-v2-gate.test.ts": "codex-integration", + "codex-agent-thread-limit.test.ts": "codex-integration", "codex-warmup.test.ts": "codex-integration", "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", "coding-agent-tool-result-images.test.ts": "adapters", "cold-spawn-warmup.test.ts": "ci-workflows", From e9c1a224ccfe7db1e03fe31f3dc3a3a03a5861b7 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:20:13 +0900 Subject: [PATCH 2/4] test(codex): require canonical limit after atomic feature toggle Carry #7125 from 4bc46305ec63ed62b4f8db22b8f74a0b9460f612. The write-lock regression must assert the canonical agents table after V2-to-V1 conversion, including absence of a legacy alias. Correct the API field name in the capacity contract. Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> --- structure/subagent-capacity.md | 2 +- tests/codex-integration/codex-config-write-lock.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/subagent-capacity.md b/structure/subagent-capacity.md index 34a6be11f0e..48fb171be3b 100644 --- a/structure/subagent-capacity.md +++ b/structure/subagent-capacity.md @@ -60,7 +60,7 @@ including hybrid mode with the global `multi_agent_v2` flag disabled. Its displa children only. Surface selection and capacity are independent; raising the cap neither spawns children automatically nor guarantees faster execution. -`GET/PUT /api/v2` retains its existing `maxThreads` units: with `enabled: false`, N means N +`GET/PUT /api/v2` retains its existing `maxConcurrentThreadsPerSession` units: with `enabled: false`, N means N children; with `enabled: true`, N+1 includes the primary thread. The GUI subtracts one for display and adds one on save only when that global flag is enabled. Catalog-selected v2 in hybrid mode does not change those API units. An unset limit leaves the default to the native runtime. diff --git a/tests/codex-integration/codex-config-write-lock.test.ts b/tests/codex-integration/codex-config-write-lock.test.ts index 5c7bda50366..182785c4204 100644 --- a/tests/codex-integration/codex-config-write-lock.test.ts +++ b/tests/codex-integration/codex-config-write-lock.test.ts @@ -527,7 +527,7 @@ test("a successful feature toggle that atomically replaces config advances the h require("node:fs").renameSync(replacement, config); }, { configPath: config }); expect(result).toMatchObject({ ok: true, changed: true, threadLimit: 63 }); - expect(readFileSync(config, "utf8")).toContain("max_threads = 63"); + expect(Bun.TOML.parse(readFileSync(config, "utf8")).agents).toEqual({ max_concurrent_threads_per_session: 63 }); }); describe("SQLite order and retained recovery authority", () => { From 1f32dd3ca011e4394ea862264585127e63385bf3 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:23:42 +0900 Subject: [PATCH 3/4] docs(codex): name the thread-limit API field consistently --- structure/gui-and-management-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c9f3c5a8561..972e0a1509c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -281,7 +281,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host` and `POST /api/link/apply`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; `POST /api/link/join` instead requires an operator-paired session because it durably redirects local client traffic, and also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` and typed `joinDenied` causes only to GUI-session callers, distinguishing pairing, runtime-role and port refusals without changing the admin/CLI DTO, so the dashboard enables the Child role only when the join gates pass; `GET /api/link/status`, `POST /api/link/{id}/reconnect` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress, and reconnect restarts a Home-initiated link's tunnel now; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). 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. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | 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`), the `keepNativeChatGptOnV1` hybrid pin, and `maxThreads`. That API value counts children with `enabled: false` and includes the primary thread with `enabled: true`. The Models page's Model settings panel always displays children and exposes the limit even when the global flag is disabled; see [concurrent child limit](subagents.md#concurrent-child-limit). Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | +| 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`), the `keepNativeChatGptOnV1` hybrid pin, and `maxConcurrentThreadsPerSession`. That API value counts children with `enabled: false` and includes the primary thread with `enabled: true`. The Models page's Model settings panel always displays children and exposes the limit even when the global flag is disabled; see [concurrent child limit](subagent-capacity.md#concurrent-child-limit). Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | Reached through the Usage & Logs row’s section switcher (`/#logs`), with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. 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` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. Fast observations and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority): the provider editor classifies `responseTierAuthoritative` as an operator-owned boolean under the existing redaction and write boundaries, `src/usage/log.ts` retains it on final outcomes and attempts (old records keep their legacy meaning), and `src/usage/cost.ts` never prices a non-authoritative echo or confirmed label as response-confirmed. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | From c0a798c1ba8a1c050262ea99ae542aaea92a6340 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:28:33 +0900 Subject: [PATCH 4/4] fix(codex): identify both agents limit keys in conflict warnings --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/tests/models-subagent-limit.test.tsx | 9 +++++++++ src/cli/v2.ts | 2 +- structure/subagent-capacity.md | 1 + tests/cli/cli-v2-runtime.test.ts | 7 +++++++ 15 files changed, 29 insertions(+), 12 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index e138211c0e4..30a696864a2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -777,7 +777,7 @@ export const de: Record = { "models.keepNativeOnV1Hint": "ChatGPT-native Eltern verschlüsseln v2-Kindaufgaben — Grok und Claude können sie nicht lesen. An lassen, wenn Sol/Terra weiterhin geroutete Modelle starten sollen. Geroutete Eltern bleiben auf v2.", "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nUnter v2 lässt „ChatGPT auf v1 lassen“ Sol/Terra auf v1, damit sie weiter Grok oder Claude starten können. ChatGPT verschlüsselt v2-Kindaufgaben; geroutete Modelle können sie nicht lesen. Geroutete Eltern bleiben auf v2.\n\nÄnderungen gelten für neue Sitzungen.", "dash.multiAgent": "Sub-Agent", - "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", "models.v2Applied": "Sub-Agent-Modus aktualisiert — gilt für neue Sitzungen (Codex-App neu starten, um die Auswahl zu aktualisieren)", "models.v2ThreadsLabel": "Sub-Agent-Limit", "models.v2ThreadsDefault": "Standard", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8c75a474896..06bdd12fb3a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -807,7 +807,7 @@ export const en = { "dash.codexClientCompaction": "Use client-side compaction", "dash.codexClientCompactionHint": "Off by default; authenticated loopback only. Future compactions store portable plaintext summaries while OpenCodeX and V2 provider routing stay active; the configured provider may process them and consume quota. History is left untouched, and existing threads keep routing through the proxy via the openai_base_url override OpenCodeX manages; if you set that line yourself it is kept, and those threads follow your destination instead. Existing ocx1 history stays recoverable; recover a thread separately only before replaying it in native Codex. Restart Codex after changing this setting.", "dash.codexRemoteHistoryHint": "Provider-table routing can hide existing openai-tagged threads in some mobile remote lists. History is not deleted. The remote client must list all providers; this switch does not repair that client filter.", - "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", "models.v2ThreadsLabel": "Sub-agent limit", "models.v2ThreadsDefault": "default", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 39466c5ff31..21a117b5f5b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -792,7 +792,7 @@ export const fr: Record = { "dash.codexClientCompaction": "Utiliser la compaction côté client", "dash.codexClientCompactionHint": "Désactivé par défaut, uniquement pour le routage loopback authentifié. Les compactages futurs stockent des résumés portables en texte clair tout en conservant le routage OpenCodeX/V2 ; le fournisseur configuré peut les traiter et consommer son quota. L'historique ocx1 existant doit toujours être restauré. Redémarrez Codex après modification.", "dash.codexRemoteHistoryHint": "Le routage par table de fournisseurs peut masquer les fils existants marqués openai dans certaines listes mobiles distantes. L’historique n’est pas supprimé. Le client distant doit lister tous les fournisseurs ; ce réglage ne corrige pas son filtre.", - "models.v2Conflict": "[agents] max_threads est défini — codex refusera de démarrer ; supprimez-le de config.toml", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads est défini — codex refusera de démarrer ; supprimez-le de config.toml", "models.v2Applied": "Mode sous-agent mis à jour — s’applique aux nouvelles sessions (redémarrez l’application Codex pour actualiser le sélecteur)", "models.v2ThreadsLabel": "Limite de sous-agents", "models.v2ThreadsDefault": "par défaut", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 05070b99b50..e527221b0e0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -784,7 +784,7 @@ export const ja: Record = { "models.keepNativeOnV1Hint": "ChatGPT ネイティブの親は v2 子タスクを暗号化するため、Grok や Claude は読めません。Sol/Terra から routed モデルを spawn するならオンのまま。routed 親は v2 のままです。", "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\nv2 では「ChatGPT を v1 のまま」にすると Sol/Terra が v1 に留まり、Grok や Claude を spawn できます。ChatGPT は v2 子タスクを暗号化するため routed モデルは読めません。routed 親は v2 のままです。\n\n変更は新規セッションに適用されます。", "dash.multiAgent": "サブエージェント", - "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", "models.v2Applied": "サブエージェントモードを更新しました — 新規セッションに適用(ピッカーを更新するには Codex アプリを再起動)", "models.v2ThreadsLabel": "サブエージェント上限", "models.v2ThreadsDefault": "デフォルト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a9f549c4c8c..149cb2c7040 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -788,7 +788,7 @@ export const ko: Record = { "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\nv2에서 ChatGPT는 v1 유지를 켜면 Sol/Terra가 v1에 남아 Grok이나 Claude를 spawn할 수 있습니다. ChatGPT는 v2 자식 작업을 암호화하므로 routed 모델은 읽지 못합니다. routed 부모는 v2를 유지합니다.\n\n새 세션부터 적용됩니다.", "models.v2DocsLink": "v1 / v2가 뭔가요?", "dash.multiAgent": "서브에이전트", - "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", "models.v2Applied": "서브에이전트 모드 변경됨 — 새 세션부터 적용 (피커 갱신은 Codex 앱 재시작)", "models.v2ThreadsLabel": "서브에이전트 한도", "models.v2ThreadsDefault": "기본값", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 7e9a457b4e7..39c63c89b4b 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -806,7 +806,7 @@ export const pt: Record = { "dash.codexClientCompaction": "Usar compactação no cliente", "dash.codexClientCompactionHint": "Desativado por padrão; somente loopback autenticado. As próximas compactações armazenam resumos portáveis em texto simples enquanto o roteamento de provedores do OpenCodeX e da V2 permanece ativo; o provedor configurado pode processá-los e consumir cota. O histórico não é alterado, e as threads existentes continuam roteando pelo proxy por meio do override openai_base_url gerenciado pelo OpenCodeX; se você definir essa linha manualmente, ela é mantida, e essas threads seguem o seu destino. O histórico ocx1 existente continua recuperável; recupere uma thread separadamente apenas antes de reproduzi-la no Codex nativo. Reinicie o Codex após alterar esta configuração.", "dash.codexRemoteHistoryHint": "O roteamento por tabela de provedores pode ocultar threads existentes marcadas como openai em algumas listas remotas do celular. O histórico não é excluído. O cliente remoto precisa listar todos os provedores; esta opção não corrige o filtro desse cliente.", - "models.v2Conflict": "[agents] max_threads está definido — o codex se recusará a iniciar; remova-o do config.toml", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads está definido — o codex se recusará a iniciar; remova-o do config.toml", "models.v2Applied": "Modo de subagente atualizado — vale para novas sessões (reinicie o app do Codex para atualizar o seletor)", "models.v2ThreadsLabel": "Limite de subagentes", "models.v2ThreadsDefault": "padrão", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5463185e908..57d19cfff27 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -790,7 +790,7 @@ export const ru: Record = { "models.keepNativeOnV1Hint": "Нативные родители ChatGPT шифруют дочерние задачи v2 — Grok и Claude их не читают. Оставьте включённым, если Sol/Terra должны порождать routed-модели. Routed-родители остаются на v2.", "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nНа v2 «Оставить ChatGPT на v1» оставляет Sol/Terra на v1, чтобы они могли порождать Grok или Claude. ChatGPT шифрует дочерние задачи v2 — routed-модели их не читают. Routed-родители остаются на v2.\n\nИзменения применяются к новым сессиям.", "dash.multiAgent": "Подагент", - "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", + "models.v2Conflict": "Задан [agents] max_concurrent_threads_per_session / max_threads — codex откажется запускаться; удалите его из config.toml", "models.v2Applied": "Режим подагента обновлён — применяется к новым сессиям (перезапустите приложение Codex, чтобы обновить селектор моделей)", "models.v2ThreadsLabel": "Лимит подагентов", "models.v2ThreadsDefault": "по умолчанию", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 56920b2b18a..25fdbf2023b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -793,7 +793,7 @@ export const tr: Record = { "models.keepNativeOnV1Hint": "ChatGPT yerel ebeveynleri v2 çocuk görevlerini şifreler; Grok ve Claude okuyamaz. Sol/Terra yönlendirilmiş modelleri spawn edecekse açık bırakın. Yönlendirilmiş ebeveynler v2'de kalır.", "models.v2Help": "v1 alt ajanları birincil modelle sınırlandırır; taban standart ajan sınırlarını devralır; v2 tam çoklu ajan orkestrasyonunu etkinleştirir.\n\nv2'de ChatGPT v1'de kalsın, Sol/Terra'yı v1'de bırakır; böylece Grok veya Claude spawn edebilirler. ChatGPT v2 çocuk görevlerini şifreler; yönlendirilmiş modeller okuyamaz. Yönlendirilmiş ebeveynler v2'de kalır.\n\nDeğişiklikler yeni oturumlara uygulanır.", "dash.multiAgent": "Alt Ajan", - "models.v2Conflict": "[agents] max_threads ayarlanmış — config.toml dosyasından kaldırın", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads ayarlanmış — config.toml dosyasından kaldırın", "models.v2Applied": "Çoklu ajan modu güncellendi. Yeni oturumlar bu modu kullanacaktır; model seçiciyi yenilemek için Codex uygulamasını yeniden başlatın.", "models.v2ThreadsLabel": "Alt ajan limiti", "models.v2ThreadsDefault": "varsayılan", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 77428a0ab86..d891ad5b689 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -792,7 +792,7 @@ export const vi: Record = { "dash.codexClientCompaction": "Sử dụng tính năng thu gọn phía client (client-side compaction)", "dash.codexClientCompactionHint": "Mặc định tắt; chỉ dành cho authenticated loopback. Các compactions trong tương lai sẽ lưu trữ các bản tóm tắt văn bản thuần trong khi OpenCodeX và V2 provider routing vẫn hoạt động; provider được cấu hình có thể xử lý chúng và tiêu tốn quota. Lịch sử được giữ nguyên, và các luồng (threads) hiện tại tiếp tục định tuyến qua proxy thông qua ghi đè openai_base_url mà OpenCodeX quản lý; nếu bạn tự thiết lập dòng đó, nó sẽ được giữ lại và các luồng đó sẽ tuân theo đích đến của bạn thay thế. Lịch sử ocx1 hiện tại vẫn có thể khôi phục được; chỉ khôi phục một luồng riêng biệt trước khi phát lại (replay) trong Codex native. Khởi động lại Codex sau khi thay đổi cài đặt này.", "dash.codexRemoteHistoryHint": "Định tuyến bằng bảng nhà cung cấp có thể ẩn các luồng mang nhãn openai hiện có khỏi một số danh sách từ xa trên điện thoại. Lịch sử không bị xóa. Client từ xa phải liệt kê mọi nhà cung cấp; công tắc này không sửa bộ lọc của client.", - "models.v2Conflict": "[agents] max_threads đã được thiết lập — codex sẽ từ chối khởi động; hãy xoá nó khỏi config.toml", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads đã được thiết lập — codex sẽ từ chối khởi động; hãy xoá nó khỏi config.toml", "models.v2Applied": "Chế độ agent con đã được cập nhật — áp dụng cho các phiên mới (khởi động lại ứng dụng Codex để làm mới picker)", "models.v2ThreadsLabel": "Giới hạn sub-agent", "models.v2ThreadsDefault": "mặc định", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2f37a9cf51b..3dc5809f2b1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -651,7 +651,7 @@ export const zhTW: Record = { "models.keepNativeOnV1Hint": "ChatGPT 原生父代理會加密 v2 子任務,Grok/Claude 無法讀取。若 Sol/Terra 仍需派發路由模型,請保持開啟。路由父代理仍使用 v2。", "models.v2Help": "控制所有模型的多代理介面。\n\nv1: 經典單執行緒代理。所有模型使用 v1 協作介面。\nbase: 上游預設值 — sol/terra 使用 v2,luna 使用 v1,其餘跟隨 codex 功能標誌。\nv2: 多執行緒代理(spawn_agent)。所有模型使用 v2 協作介面。\n\n在 v2 下,「ChatGPT 維持 v1」會讓 Sol/Terra 留在 v1,以便繼續派發 Grok 或 Claude。ChatGPT 會加密 v2 子任務,路由模型無法讀取;路由父代理仍留在 v2。\n\n更改在新會話中生效。", "dash.multiAgent": "子代理", - "models.v2Conflict": "[agents] max_threads 仍存在 — codex 將拒絕啟動,請從 config.toml 移除", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads 仍存在 — codex 將拒絕啟動,請從 config.toml 移除", "models.v2Applied": "子代理模式已更新 — 新會話生效(重新啟動 Codex 應用以重新整理選擇器)", "models.v2ThreadsLabel": "子代理上限", "models.v2ThreadsDefault": "預設", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2e2bc4bbc8a..43f844990b8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -785,7 +785,7 @@ export const zh: Record = { "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n在 v2 下,「ChatGPT 保持 v1」会让 Sol/Terra 留在 v1,以便继续派发 Grok 或 Claude。ChatGPT 会加密 v2 子任务,路由模型无法读取;路由父代理仍留在 v2。\n\n更改在新会话中生效。", "models.v2DocsLink": "v1 / v2 是什么?", "dash.multiAgent": "子代理", - "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", + "models.v2Conflict": "[agents] max_concurrent_threads_per_session / max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", "models.v2Applied": "子代理模式已更新 — 新会话生效(重启 Codex 应用以刷新选择器)", "models.v2ThreadsLabel": "子代理上限", "models.v2ThreadsDefault": "默认", diff --git a/gui/tests/models-subagent-limit.test.tsx b/gui/tests/models-subagent-limit.test.tsx index bc0b46f72c7..dc833cffaa5 100644 --- a/gui/tests/models-subagent-limit.test.tsx +++ b/gui/tests/models-subagent-limit.test.tsx @@ -196,6 +196,15 @@ test("full v2 presents children (stored total minus the root slot) and PUTs tota expect(lastPut()).toEqual({ maxConcurrentThreadsPerSession: 33 }); }); +test("the V2 conflict warning identifies canonical agents limits as well as the legacy alias", async () => { + testWindow.localStorage.setItem("ocx.models.settingsOpen.v1", "1"); + v2Body = { enabled: true, multiAgentMode: "v2", agentsMaxThreadsConflict: true, maxConcurrentThreadsPerSession: 17 }; + await mount(); + + const warning = container.querySelector(".models-v2-detail-row"); + expect(warning?.textContent).toContain("[agents] max_concurrent_threads_per_session / max_threads"); +}); + test("the folded settings summary names the returned child limit in any mode", async () => { // Panel stays folded (no settingsOpen key): the summary line must still carry the value. v2Body = { enabled: false, multiAgentMode: "v1", maxConcurrentThreadsPerSession: 16 }; diff --git a/src/cli/v2.ts b/src/cli/v2.ts index 8671cc9c530..09e8927915e 100644 --- a/src/cli/v2.ts +++ b/src/cli/v2.ts @@ -148,7 +148,7 @@ function printLocalStatus(deps: V2CliDeps): number { const modeHint = getMultiAgentModeHintText(); log.log(`multi_agent_mode_hint_text: ${modeHint === null ? "(unset — effort-derived policy: ultra=proactive, else explicit)" : JSON.stringify(modeHint)}`); if (isEnabled() && hasMaxThreads()) { - log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session)."); + log.log("WARNING: [agents] max_concurrent_threads_per_session / max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove the agents limit from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session)."); } return 0; } diff --git a/structure/subagent-capacity.md b/structure/subagent-capacity.md index 48fb171be3b..3ab5d6bdfb6 100644 --- a/structure/subagent-capacity.md +++ b/structure/subagent-capacity.md @@ -73,3 +73,4 @@ limit resolves them. Unsupported or unsafe writes refuse before mutation. With t flag disabled, a limit edit updates the canonical child count and removes a stale feature-level v2 limit without enabling that flag. These rules establish configuration consistency; they do not attest the effective capacity of a running native or routed session. +CLI and dashboard global-v2 conflict warnings name both supported agents keys. diff --git a/tests/cli/cli-v2-runtime.test.ts b/tests/cli/cli-v2-runtime.test.ts index fa8789f0c0b..d77b17625bc 100644 --- a/tests/cli/cli-v2-runtime.test.ts +++ b/tests/cli/cli-v2-runtime.test.ts @@ -167,6 +167,13 @@ describe("live V2 fixed management operations", () => { }); describe("local V2 state and synchronization graph", () => { + test("status identifies the canonical agents key in a global-V2 conflict", async () => { + writeFileSync(path, "[features.multi_agent_v2]\nenabled = true\n\n[agents]\nmax_concurrent_threads_per_session = 16\n"); + const local = localDeps(); + expect(await cmdV2(["status"], local.deps)).toBe(0); + expect(out.join("\n")).toContain("WARNING: [agents] max_concurrent_threads_per_session / max_threads is set"); + expect(local.actions).toEqual([]); + }); test.each([["mode", "default"], ["keep-native-v1", "off"], ["on"]].map(args => [args] as const))( "required sync runs with undefined port for %j", async args => { const local = localDeps();