mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
@@ -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" }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user