diff --git a/docs-site/public/pr-screenshots/subagent-fallback-settings.png b/docs-site/public/pr-screenshots/subagent-fallback-settings.png new file mode 100644 index 0000000000..04f72f140c Binary files /dev/null and b/docs-site/public/pr-screenshots/subagent-fallback-settings.png differ diff --git a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx index 46c0447a7c..f3269963e1 100644 --- a/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx +++ b/gui/src/components/subagents-workspace/SubagentDelegationSection.tsx @@ -28,6 +28,14 @@ export interface SubagentDelegationSectionProps { onUltraModeSave: (patch: UltraModePatch) => void; ultraLoadFailed: boolean; onUltraModeRetry: () => void; + fallback: string[]; + fallbackPollMs: number; + fallbackPollError?: string; + fallbackBusy: boolean; + availableModels: string[]; + onFallbackChange: (models: string[]) => void; + onFallbackPollMsChange: (pollMs: number) => void; + onFallbackSave: () => void; } export default function SubagentDelegationSection({ @@ -44,6 +52,7 @@ export default function SubagentDelegationSection({ onUltraModeSave, ultraLoadFailed, onUltraModeRetry, + fallback, fallbackPollMs, fallbackPollError, fallbackBusy, availableModels, onFallbackChange, onFallbackPollMsChange, onFallbackSave, }: SubagentDelegationSectionProps) { const t = useT(); // A present empty/whitespace hint is an upstream override that suppresses the @@ -97,6 +106,32 @@ export default function SubagentDelegationSection({ +
+
+
{t("sub.fallbackLabel")}
+
{t("sub.fallbackHint")}
+
+
+ {fallback.map((modelName, index) => ( +
+ {index + 1}. {modelName} + + + +
+ ))} + + + {fallbackPollError && } + +
+
+
{t("dash.syncCodexSubagentDefaults")}
diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index a22bd2a305..b67a4af592 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -37,6 +37,13 @@ export interface SubagentsWorkspaceProps { onToggle: (m: string) => void; onMove: (i: number, dir: -1 | 1) => void; onSave: () => void; + fallback: string[]; + fallbackPollMs: number; + fallbackPollError?: string; + fallbackBusy: boolean; + onFallbackChange: (models: string[]) => void; + onFallbackPollMsChange: (pollMs: number) => void; + onFallbackSave: () => void; delegation: { model: string; effort: string; @@ -63,6 +70,7 @@ export default function SubagentsWorkspace({ onToggle, onMove, onSave, + fallback, fallbackPollMs, fallbackPollError, fallbackBusy, onFallbackChange, onFallbackPollMsChange, onFallbackSave, delegation, }: SubagentsWorkspaceProps) { const t = useT(); @@ -237,6 +245,14 @@ export default function SubagentsWorkspace({ onUltraModeSave={delegation.onUltraModeSave} ultraLoadFailed={delegation.ultraLoadFailed} onUltraModeRetry={delegation.onUltraModeRetry} + fallback={fallback} + fallbackPollMs={fallbackPollMs} + fallbackPollError={fallbackPollError} + fallbackBusy={fallbackBusy} + availableModels={available} + onFallbackChange={onFallbackChange} + onFallbackPollMsChange={onFallbackPollMsChange} + onFallbackSave={onFallbackSave} />
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 429379396f..dd331731f6 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -672,6 +672,14 @@ export const de: Record = { "sub.ultraModeLoadFail": "Ultra-Modus-Einstellungen konnten nicht geladen werden — läuft der Proxy?", "sub.ultraModeSaveFail": "Ultra-Modus-Einstellungen konnten nicht gespeichert werden", "sub.ultraModeSaved": "Ultra-Modus gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.fallbackLabel": "Fallback-Kette für Sub-Agenten", + "sub.fallbackHint": "Geordnete Modelle, die versucht werden, wenn ein Sub-Agent-Modell nicht verfügbar ist oder fehlschlägt.", + "sub.fallbackAdd": "Fallback-Modell hinzufügen…", + "sub.fallbackPoll": "Intervall der Verfügbarkeitsprüfung", + "sub.fallbackPollUnit": "ms", + "sub.fallbackPollInvalid": "Geben Sie eine ganze Zahl von {min} bis {max} Millisekunden ein.", + "sub.fallbackSaved": "Fallback-Einstellungen für Sub-Agenten gespeichert.", + "sub.fallbackSaveFailed": "Fallback-Einstellungen konnten nicht gespeichert werden", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9cbf8699fd..a2c2615350 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -315,6 +315,14 @@ export const en = { "dash.visionTimeout": "Timeout", "dash.visionTimeoutInvalid": "Enter an integer from {min} to {max} milliseconds.", "dash.visionAdvancedPopover": "Advanced vision settings", + "sub.fallbackLabel": "Sub-agent fallback chain", + "sub.fallbackHint": "Ordered models tried when a sub-agent model is unavailable or fails.", + "sub.fallbackAdd": "Add fallback model…", + "sub.fallbackPoll": "Availability check interval", + "sub.fallbackPollUnit": "ms", + "sub.fallbackPollInvalid": "Enter a whole number from {min} to {max} milliseconds.", + "sub.fallbackSaved": "Sub-agent fallback settings saved.", + "sub.fallbackSaveFailed": "Failed to save fallback settings", "dash.shadowCallIntercept": "Shadow Call Intercept", "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.", "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ec171e627c..9588ecff29 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -305,6 +305,14 @@ export const fr: Record = { "dash.visionTimeout": "Délai d’expiration", "dash.visionTimeoutInvalid": "Saisissez un entier compris entre {min} et {max} millisecondes.", "dash.visionAdvancedPopover": "Paramètres de vision avancés", + "sub.fallbackLabel": "Chaîne de secours des sous-agents", + "sub.fallbackHint": "Modèles essayés dans l’ordre lorsqu’un modèle de sous-agent est indisponible ou échoue.", + "sub.fallbackAdd": "Ajouter un modèle de secours…", + "sub.fallbackPoll": "Intervalle de vérification de disponibilité", + "sub.fallbackPollUnit": "ms", + "sub.fallbackPollInvalid": "Saisissez un nombre entier compris entre {min} et {max} millisecondes.", + "sub.fallbackSaved": "Paramètres de secours des sous-agents enregistrés.", + "sub.fallbackSaveFailed": "Échec de l’enregistrement des paramètres de secours", "dash.shadowCallIntercept": "Interception des appels fantômes", "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.", "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c71bd7a045..6daa2231d3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -632,6 +632,14 @@ export const ja: Record = { "sub.ultraModeLoadFail": "ウルトラモード設定を読み込めませんでした — プロキシは実行中ですか?", "sub.ultraModeSaveFail": "ウルトラモード設定の保存に失敗しました", "sub.ultraModeSaved": "ウルトラモードを保存しました。新しい Codex セッションから適用されます。", + "sub.fallbackLabel": "サブエージェントのフォールバックチェーン", + "sub.fallbackHint": "サブエージェントモデルが利用できないか失敗した場合に順番に試すモデルです。", + "sub.fallbackAdd": "フォールバックモデルを追加…", + "sub.fallbackPoll": "利用可能性チェック間隔", + "sub.fallbackPollUnit": "ミリ秒", + "sub.fallbackPollInvalid": "{min} から {max} ミリ秒までの整数を入力してください。", + "sub.fallbackSaved": "サブエージェントのフォールバック設定を保存しました。", + "sub.fallbackSaveFailed": "フォールバック設定の保存に失敗しました", // logs "logs.title": "リクエストログ", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 63ac304426..066116c759 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -689,6 +689,14 @@ export const ko: Record = { "sub.ultraModeLoadFail": "울트라 모드 설정을 불러오지 못했습니다 — 프록시가 실행 중인가요?", "sub.ultraModeSaveFail": "울트라 모드 설정 저장에 실패했습니다", "sub.ultraModeSaved": "울트라 모드가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.fallbackLabel": "서브에이전트 폴백 체인", + "sub.fallbackHint": "서브에이전트 모델을 사용할 수 없거나 실패할 때 순서대로 시도할 모델입니다.", + "sub.fallbackAdd": "폴백 모델 추가…", + "sub.fallbackPoll": "가용성 확인 간격", + "sub.fallbackPollUnit": "밀리초", + "sub.fallbackPollInvalid": "{min}에서 {max} 밀리초 사이의 정수를 입력하세요.", + "sub.fallbackSaved": "서브에이전트 폴백 설정을 저장했습니다.", + "sub.fallbackSaveFailed": "폴백 설정을 저장하지 못했습니다", // logs "logs.title": "요청 로그", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9f220ba2b1..ffb7ecc554 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -687,6 +687,14 @@ export const ru: Record = { "sub.ultraModeLoadFail": "Не удалось загрузить настройки ультра-режима — работает ли прокси?", "sub.ultraModeSaveFail": "Не удалось сохранить настройки ультра-режима", "sub.ultraModeSaved": "Ультра-режим сохранён. Применяется к новым сеансам Codex.", + "sub.fallbackLabel": "Цепочка резервных моделей субагента", + "sub.fallbackHint": "Модели, которые последовательно пробуются, если модель субагента недоступна или завершается ошибкой.", + "sub.fallbackAdd": "Добавить резервную модель…", + "sub.fallbackPoll": "Интервал проверки доступности", + "sub.fallbackPollUnit": "мс", + "sub.fallbackPollInvalid": "Введите целое число от {min} до {max} миллисекунд.", + "sub.fallbackSaved": "Настройки резервных моделей субагента сохранены.", + "sub.fallbackSaveFailed": "Не удалось сохранить настройки резервных моделей", // logs "logs.title": "Журнал запросов", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index aee152cd39..c6aeb3dfbe 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -694,6 +694,14 @@ export const tr: Record = { "sub.ultraModeLoadFail": "Ultra modu ayarları yüklenemedi — proxy çalışıyor mu?", "sub.ultraModeSaveFail": "Ultra modu ayarları kaydedilemedi", "sub.ultraModeSaved": "Ultra modu kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.fallbackLabel": "Alt ajan yedek zinciri", + "sub.fallbackHint": "Alt ajan modeli kullanılamadığında veya başarısız olduğunda sırayla denenecek modeller.", + "sub.fallbackAdd": "Yedek model ekle…", + "sub.fallbackPoll": "Kullanılabilirlik kontrol aralığı", + "sub.fallbackPollUnit": "ms", + "sub.fallbackPollInvalid": "{min} ile {max} milisaniye arasında tam sayı girin.", + "sub.fallbackSaved": "Alt ajan yedek ayarları kaydedildi.", + "sub.fallbackSaveFailed": "Yedek ayarlar kaydedilemedi", // logs "logs.title": "İstek Günlükleri", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 39c9e2f0b3..2c91f1ff56 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -541,6 +541,14 @@ export const zhTW: Record = { "sub.ultraModeLoadFail": "無法載入超級模式設定 — 代理是否在執行?", "sub.ultraModeSaveFail": "儲存超級模式設定失敗", "sub.ultraModeSaved": "超級模式已儲存。適用於新的 Codex 會話。", + "sub.fallbackLabel": "子代理備援鏈", + "sub.fallbackHint": "子代理模型無法使用或失敗時,依序嘗試的模型。", + "sub.fallbackAdd": "新增備援模型…", + "sub.fallbackPoll": "可用性檢查間隔", + "sub.fallbackPollUnit": "毫秒", + "sub.fallbackPollInvalid": "請輸入介於 {min} 與 {max} 毫秒之間的整數。", + "sub.fallbackSaved": "子代理備援設定已儲存。", + "sub.fallbackSaveFailed": "備援設定儲存失敗", "logs.title": "請求日誌", "logs.tabLogs": "日誌", "logs.tabDebug": "除錯", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1ba4cabfa8..04d5be1ea7 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -682,6 +682,14 @@ export const zh: Record = { "sub.ultraModeLoadFail": "无法加载超级模式设置 — 代理是否在运行?", "sub.ultraModeSaveFail": "保存超级模式设置失败", "sub.ultraModeSaved": "超级模式已保存。适用于新的 Codex 会话。", + "sub.fallbackLabel": "子代理回退链", + "sub.fallbackHint": "子代理模型不可用或失败时按顺序尝试的模型。", + "sub.fallbackAdd": "添加回退模型…", + "sub.fallbackPoll": "可用性检查间隔", + "sub.fallbackPollUnit": "毫秒", + "sub.fallbackPollInvalid": "请输入 {min} 到 {max} 毫秒之间的整数。", + "sub.fallbackSaved": "子代理回退设置已保存。", + "sub.fallbackSaveFailed": "保存回退设置失败", // logs "logs.title": "请求日志", diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 6b54d39ffd..76364744a0 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -8,7 +8,7 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { useSubagentDelegation, type UltraModePatch, type UltraModeState } from "./use-subagent-delegation"; -type CachedSubagents = { available: string[]; chosen: string[] }; +type CachedSubagents = { available: string[]; chosen: string[]; fallback: string[]; pollMs: number }; function seedSubagents(cacheKey: string): CachedSubagents | null { return readSessionListCache(cacheKey); @@ -19,6 +19,10 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const cacheKey = `ocx.subagents.v1:${apiBase}`; const cached = seedSubagents(cacheKey); const [chosen, setChosen] = useState(() => cached?.chosen ?? []); + const [fallback, setFallback] = useState(() => cached?.fallback ?? []); + const [fallbackPollMs, setFallbackPollMs] = useState(() => cached?.pollMs ?? 60000); + const [fallbackPollError, setFallbackPollError] = useState(""); + const [fallbackBusy, setFallbackBusy] = useState(false); const [status, setStatus] = useState(""); const [ok, setOk] = useState(false); const [busy, setBusy] = useState(false); @@ -117,16 +121,24 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const loadSubagents = useCallback(async (signal?: AbortSignal): Promise => { // The resource layer's deadline abort must reach the wire — a signal dropped // here is a store that can only settle by race timeout. - const res = await fetch(`${apiBase}/api/subagent-models`, { signal }); - const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(res, t("sub.loadFail")); - if (!response) throw new Error(t("sub.loadFail")); - const available = response.available ?? []; + const [rosterRes, fallbackRes] = await Promise.all([ + fetch(`${apiBase}/api/subagent-models`, { signal }), + fetch(`${apiBase}/api/subagent-model-fallback`, { signal }), + ]); + const response = await readJsonOrThrow<{ available?: string[]; chosen?: string[] }>(rosterRes, t("sub.loadFail")); + const fallbackResponse = await readJsonOrThrow<{ available?: string[]; models?: string[]; pollMs?: number }>(fallbackRes, t("sub.loadFail")); + if (!response || !fallbackResponse) throw new Error(t("sub.loadFail")); + const available = response.available ?? fallbackResponse.available ?? []; const availableSet = new Set(available); const next = { available, chosen: (response.chosen ?? []).filter(model => availableSet.has(model)), + fallback: (fallbackResponse.models ?? []).filter(model => availableSet.has(model)), + pollMs: fallbackResponse.pollMs ?? 60000, }; setChosen(next.chosen); + setFallback(next.fallback); + setFallbackPollMs(next.pollMs); writeSessionListCache(cacheKey, next); return next; }, [apiBase, cacheKey, t]); @@ -174,7 +186,11 @@ export default function Subagents({ apiBase }: { apiBase: string }) { const d = await readJsonOrThrow<{ applied?: string[] }>(r, t("sub.saveFailed")); const applied = d?.applied ?? chosen; if (d?.applied) setChosen(d.applied); - writeSessionListCache(cacheKey, { available, chosen: applied }); + writeSessionListCache(cacheKey, { + ...(seedSubagents(cacheKey) ?? { available, chosen: [], fallback: [], pollMs: 60000 }), + available, + chosen: applied, + }); setOk(true); setStatus(t("sub.saved", { n: applied.length, cmd: "ocx sync" })); } catch (error) { @@ -186,6 +202,44 @@ export default function Subagents({ apiBase }: { apiBase: string }) { } }; + const saveFallback = async () => { + if (fallbackBusy) return; + if (!Number.isInteger(fallbackPollMs) || fallbackPollMs < 5000 || fallbackPollMs > 600000) { + const message = t("sub.fallbackPollInvalid", { min: 5000, max: 600000 }); + setFallbackPollError(message); + setOk(false); + setStatus(message); + return; + } + setFallbackBusy(true); + setFallbackPollError(""); + try { + const r = await fetch(`${apiBase}/api/subagent-model-fallback`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ models: fallback, pollMs: fallbackPollMs }), + }); + const d = await readJsonOrThrow<{ models?: string[]; pollMs?: number }>(r, t("sub.fallbackSaveFailed")); + const confirmedFallback = d?.models ?? fallback; + const confirmedPollMs = d?.pollMs ?? fallbackPollMs; + setFallback(confirmedFallback); + setFallbackPollMs(confirmedPollMs); + writeSessionListCache(cacheKey, { + ...(seedSubagents(cacheKey) ?? { available, chosen: [], fallback: [], pollMs: 60000 }), + available, + fallback: confirmedFallback, + pollMs: confirmedPollMs, + }); + setOk(true); + setStatus(t("sub.fallbackSaved")); + } catch (error) { + setOk(false); + setStatus(error instanceof Error && error.message ? error.message : t("sub.networkError")); + } finally { + setFallbackBusy(false); + } + }; + // The skeleton owns the live region while this resource has no content yet. if (state.showSkeleton && !snapshot) { return ; @@ -214,7 +268,14 @@ export default function Subagents({ apiBase }: { apiBase: string }) { busy={busy} onToggle={toggle} onMove={move} - onSave={() => { void save(); }} + onSave={() => { void save(); }} + fallback={fallback} + fallbackPollMs={fallbackPollMs} + fallbackPollError={fallbackPollError} + fallbackBusy={fallbackBusy} + onFallbackChange={setFallback} + onFallbackPollMsChange={pollMs => { setFallbackPollError(""); setFallbackPollMs(pollMs); }} + onFallbackSave={() => { void saveFallback(); }} delegation={{ model: delegation.model, effort: delegation.effort, diff --git a/gui/tests/subagents-classic.test.tsx b/gui/tests/subagents-classic.test.tsx index 3f24b1f4ae..5c4ad480f3 100644 --- a/gui/tests/subagents-classic.test.tsx +++ b/gui/tests/subagents-classic.test.tsx @@ -18,6 +18,8 @@ let root: Root | null = null; let requests: { url: string; init?: RequestInit }[] = []; let available: string[] = []; let chosen: string[] = []; +let fallback: string[] = []; +let pollMs = 60000; beforeEach(() => { previousGlobals = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; @@ -34,16 +36,19 @@ beforeEach(() => { requests = []; available = ["a-1", "a-2", "a-3", "a-4", "a-5", "a-6"]; chosen = []; + fallback = []; + pollMs = 60000; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { requests.push({ url: String(url), init }); - const body = JSON.stringify({ available, chosen }); + const isFallback = String(url).includes("/api/subagent-model-fallback"); + const body = JSON.stringify(isFallback ? { available, models: fallback, pollMs } : { available, chosen }); return { ok: true, status: 200, text: async () => body, - json: async () => ({ available, chosen }), + json: async () => (isFallback ? { available, models: fallback, pollMs } : { available, chosen }), } as unknown as Response; }, }); @@ -148,3 +153,18 @@ test("saves the featured order with PUT and the models payload", async () => { expect(put!.url).toContain("/api/subagent-models"); expect(put!.init?.body).toBe(JSON.stringify({ models: ["a-1", "a-2"] })); }); + +test("rejects an invalid fallback polling interval before sending a request", async () => { + await mount(); + const interval = container.querySelector('.swi-fallback-controls input[type="number"]') as HTMLInputElement; + const valueSetter = Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")?.set; + await act(async () => { + valueSetter?.call(interval, "4000"); + interval.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); + const save = Array.from(container.querySelectorAll("button")).filter(button => button.textContent?.trim() === "Save").at(-1)!; + await act(async () => { save.click(); }); + + const fallbackPut = requests.find(request => request.url.includes("/api/subagent-model-fallback") && request.init?.method === "PUT"); + expect(JSON.parse(String(fallbackPut?.init?.body)).pollMs).toBe(60000); +});