From d0737cff3b40ecbe0e9a7ad94cf1016284746769 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 02:14:09 +0900 Subject: [PATCH 1/3] fix(gui): isolate fallback choices from refreshed subagent roster --- .../SubagentsWorkspace.tsx | 4 +- gui/src/pages/Subagents.tsx | 5 +- gui/tests/subagents-fallback.test.tsx | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index 30b722b2bf..0abc8fa5fc 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -32,6 +32,7 @@ import type { DelegationPatch, DelegationModelOption, UltraModePatch, UltraModeS export interface SubagentsWorkspaceProps { available: string[]; + fallbackAvailable?: string[]; chosen: string[]; busy?: boolean; onToggle: (m: string) => void; @@ -64,6 +65,7 @@ export const FEATURED_MAX = 5; export default function SubagentsWorkspace({ available, + fallbackAvailable, chosen, busy = false, onToggle, @@ -247,7 +249,7 @@ export default function SubagentsWorkspace({ fallback={fallback} fallbackPollMs={fallbackPollMs} fallbackBusy={fallbackBusy} - availableModels={available} + availableModels={fallbackAvailable ?? available} onFallbackChange={onFallbackChange} onFallbackPollMsChange={onFallbackPollMsChange} onFallbackSave={onFallbackSave} diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index bee28ec901..0e72d76d4d 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -320,9 +320,8 @@ export default function Subagents({ apiBase }: { apiBase: string }) { )} { + available = ["a-1", "a-2"]; + fallbackAvailable = ["a-1"]; + testWindow.sessionStorage.setItem(CACHE_KEY, JSON.stringify({ + available: ["a-1"], chosen: ["a-1"], fallback: ["a-1"], pollMs: 90_000, fallbackAvailable: ["a-1"], + })); + if (status === 503) { + pendingFallbackResponse = Promise.resolve(Response.json({ error: "Fallback unavailable" }, { status: 503 })); + } else { + fallbackSettings.models = []; + } + await mount(); + + expect(pollInput().disabled).toBe(status === 503); + expect(saveButton().disabled).toBe(status === 503); + expect(labelledButton(editor(), en["sub.fallbackAdd"]).disabled).toBe(status === 503); + await click(labelledButton(container, en["sub.workspace.addToFeatured"].replace("{m}", "a-2"))); + const rosterSaveRow = container.querySelector(".swi-save-row"); + if (!rosterSaveRow) throw new Error("Roster Save row not found"); + await click(saveButton(rosterSaveRow)); + expect(putBodies(ROSTER_PATH)).toEqual([{ models: ["a-1", "a-2"] }]); + expect(cached()?.available).toEqual(["a-1", "a-2"]); + expect(cached()?.fallbackAvailable).toEqual(["a-1"]); + + if (status === 503) { + expect(container.textContent).toContain("Fallback unavailable"); + expectOrder(["a-1"]); + await act(async () => { saveButton().click(); }); + expect(putBodies()).toEqual([]); + } else { + // Neither model is already in the chain: discovery alone must exclude a-2 from fallback choices. + expectOrder([]); + const trigger = labelledButton(editor(), en["sub.fallbackAdd"]); + await click(trigger); + const listbox = testWindow.document.getElementById(trigger.getAttribute("aria-controls") ?? ""); + if (!listbox) throw new Error("Fallback model listbox not found"); + const options = Array.from(listbox.querySelectorAll('[role="option"]'), option => option.textContent?.trim()); + expect(options).toContain("a-1"); + expect(options).not.toContain("a-2"); + await click(trigger); + await addFallback("a-1"); + await click(saveButton()); + expect(putBodies()).toEqual([{ models: ["a-1"], pollMs: 45_000 }]); + } +}); + test("cached fallback availability survives remount while discovery is pending", async () => { available.push(UNAVAILABLE_MODEL); chosen = [UNAVAILABLE_MODEL, "a-1"]; From f48c322c01f19e6da0802a251724e26321402754 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 02:15:38 +0900 Subject: [PATCH 2/3] fix(gui): keep unknown fallback availability explicitly empty --- gui/src/pages/Subagents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx index 0e72d76d4d..0b9806066d 100644 --- a/gui/src/pages/Subagents.tsx +++ b/gui/src/pages/Subagents.tsx @@ -321,7 +321,7 @@ export default function Subagents({ apiBase }: { apiBase: string }) { )} Date: Tue, 8 Sep 2026 02:22:20 +0900 Subject: [PATCH 3/3] test(gui): seed fallback availability in stale-response fixtures --- gui/tests/subagents-fallback.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/tests/subagents-fallback.test.tsx b/gui/tests/subagents-fallback.test.tsx index d016e4e9d1..bf838d63ca 100644 --- a/gui/tests/subagents-fallback.test.tsx +++ b/gui/tests/subagents-fallback.test.tsx @@ -650,14 +650,14 @@ test("a legacy cache keeps fallback disabled through GET failure, roster Save, a }); test.each([false, true])("a captured old fallback GET cannot overwrite a newer draft or save (saved=%s)", async (saveNewer) => { - const committedA = { available, chosen: ["a-1"], fallback: ["a-2"], pollMs: 45_000 }; + const committedA = { available, fallbackAvailable: available, chosen: ["a-1"], fallback: ["a-2"], pollMs: 45_000 }; testWindow.sessionStorage.setItem(CACHE_KEY, JSON.stringify(committedA)); // Serialize A before any edit or PUT. Reading mutable fallbackSettings after the gate // would accidentally return B and let the stale-response regression pass. const capturedOldResponse = Response.json({ models: ["a-2"], pollMs: 45_000, available }); let releaseGet!: (response: Response) => void; pendingFallbackResponse = new Promise(resolve => { releaseGet = resolve; }); - const committedB = { available, chosen: ["a-1"], fallback: ["a-3"], pollMs: 90_000 }; + const committedB = { available, fallbackAvailable: available, chosen: ["a-1"], fallback: ["a-3"], pollMs: 90_000 }; try { await mount();