fix(desktop): surface adapter-refused live model switch as failure

The Rust pool now emits control_result { type: "switch_model", status:
"failure" } when an adapter explicitly rejects a live model switch, but
awaitLiveSwitchOutcome treated only unsupported_model as failure — every
other status, including the new failure, counted toward success and
resolved "ok". ModelPicker then toasted "Model switched for this
session." on a switch that never happened.

Add a distinct terminal "failed" outcome that fail-fasts on a single
failure frame, kept separate from "unsupported" because the user-facing
causes differ (model unavailable vs adapter refused). ModelPicker shows
a direct failure toast and skips onModelChanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-10 21:37:52 -04:00
parent 1b07e41d8e
commit 679937f0ce
3 changed files with 50 additions and 13 deletions

View File

@@ -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" }));

View File

@@ -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<void>;
/** 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) {

View File

@@ -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;