diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs index 737d84b862..6024aa1a42 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.test.mjs @@ -113,6 +113,24 @@ test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); }); +test("awaitLiveSwitchOutcome settles failed immediately on an adapter-refused frame without waiting for other channels or the timeout", async () => { + const h = harness(3); + // channelCount 3, so a success-path impl would need three acks. A single + // `failure` frame must fail-fast to "failed" (not "unsupported", not "ok") + // before the other two channels reply — proving it never traverses the + // success-count path. + h.push(frame("failure")); + assert.equal(await h.outcome, "failed"); + // The timeout was cancelled (no 8s wait) and the listener detached exactly + // once — the frame settled synchronously, not via the fallback. + assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited"); + assert.equal(h.unsubscribeCalls, 1); + + // A later frame must not re-resolve or re-unsubscribe. + h.push(frame("switched")); + assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame"); +}); + test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => { const h = harness(1); h.push(frame("sent", { modelId: "some-other-model" })); diff --git a/desktop/src/features/agents/lib/liveSwitchOutcome.ts b/desktop/src/features/agents/lib/liveSwitchOutcome.ts index d12261e596..efb3006385 100644 --- a/desktop/src/features/agents/lib/liveSwitchOutcome.ts +++ b/desktop/src/features/agents/lib/liveSwitchOutcome.ts @@ -4,12 +4,18 @@ import type { ControlResultFrame } from "@/shared/api/types"; * Resolve the outcome of a live `switch_model` across one or more channels. * * A live switch fires a `switch_model` frame per active channel and learns each - * channel's result asynchronously over the observer relay. The fail-fast rule: - * any single `unsupported_model` result rejects the whole pick immediately; - * every other status must arrive from every channel before resolving success. - * If the harness never replies, the fallback timeout resolves `"ok"` — the - * override still rides the requeued/next session, we just can't confirm it - * synchronously. + * channel's result asynchronously over the observer relay. Two statuses + * fail-fast — any single frame rejects the whole pick immediately, without + * waiting for the other channels or the timeout: + * - `unsupported_model` → the target model isn't available for this agent. + * - `failure` → the adapter refused the switch (the session stays + * on its current model). + * The causes differ, so they resolve to distinct outcomes (`"unsupported"` vs + * `"failed"`) the caller can message separately. Every other status + * (`sent` / `switched` / `turn_ending`) must arrive from every channel before + * resolving success. If the harness never replies, the fallback timeout + * resolves `"ok"` — the override still rides the requeued/next session, we just + * can't confirm it synchronously. * * The counting lives here, isolated from React and the relay so it can be unit * tested with synthetic frames and a fake clock. The caller injects the @@ -32,12 +38,12 @@ export async function awaitLiveSwitchOutcome({ sendSwitches: () => Promise; /** Schedule the no-reply fallback; returns a cancel function. */ scheduleTimeout: (onTimeout: () => void) => () => void; -}): Promise<"ok" | "unsupported"> { - const settled = new Promise<"ok" | "unsupported">((resolve) => { +}): Promise<"ok" | "unsupported" | "failed"> { + const settled = new Promise<"ok" | "unsupported" | "failed">((resolve) => { let unsubscribe = () => {}; let cancelTimeout = () => {}; let remaining = channelCount; - const finish = (outcome: "ok" | "unsupported") => { + const finish = (outcome: "ok" | "unsupported" | "failed") => { cancelTimeout(); unsubscribe(); resolve(outcome); @@ -48,10 +54,16 @@ export async function awaitLiveSwitchOutcome({ return; } if (frame.status === "unsupported_model") { - // Any single failure rejects the whole pick immediately. + // Model unavailable — reject the whole pick immediately. finish("unsupported"); return; } + if (frame.status === "failure") { + // Adapter refused the switch — reject immediately. The session stays + // on its current model; distinct outcome so the caller can say why. + finish("failed"); + return; + } // sent / switched / turn_ending — count as success for this channel. remaining -= 1; if (remaining <= 0) { diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index f7bafde99b..6fda3be4df 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -108,9 +108,10 @@ export function ModelPicker({ }, [configSurface]); // Send a live `switch_model` frame to each channel the agent is working in - // and wait for the harness to acknowledge. Any single `unsupported_model` - // result rejects the whole pick immediately; all other statuses must arrive - // from every channel before resolving success. + // and wait for the harness to acknowledge. A single `unsupported_model` + // (model unavailable) or `failure` (adapter refused) result rejects the whole + // pick immediately; all other statuses must arrive from every channel before + // resolving success. const sendLiveSwitch = React.useCallback( (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); @@ -147,6 +148,12 @@ export function ModelPicker({ toast.error("That model isn't available for this agent."); return; } + if (outcome === "failed") { + toast.error( + "Couldn't switch models — the agent kept its current model.", + ); + return; + } toast.success("Model switched for this session."); onModelChanged?.(); return;