fix: Handle model refusal fallback updates (#833)

This commit is contained in:
Ben Brandt
2026-07-02 11:21:49 +02:00
committed by GitHub
parent 8d5febf854
commit 648e3f60bd
2 changed files with 293 additions and 2 deletions

View File

@@ -1613,10 +1613,49 @@ export class ClaudeAcpAgent {
case "notification":
case "api_retry":
case "thinking_tokens":
case "model_refusal_fallback":
case "model_refusal_no_fallback":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
case "model_refusal_fallback": {
// The SDK retried a refused turn on the fallback model and made
// the swap persistent for the session. Without a notice the
// user just sees regenerated output; without the state sync the
// client's model picker (and the model-dependent options
// rebuilt from it) keeps advertising a model the session is no
// longer running.
const category = message.api_refusal_category
? ` (${message.api_refusal_category})`
: "";
const explanation = message.api_refusal_explanation
? `\n\n${message.api_refusal_explanation}`
: "";
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. The session will continue on ${message.fallback_model}.${explanation}`,
},
},
});
await this.syncModelAfterRefusalFallback(
params.sessionId,
session,
message.fallback_model,
);
break;
}
case "model_refusal_no_fallback":
// The refusal ends the turn as an error; the terminal `result`
// handler settles it with ACP's `refusal` stop reason and
// streams `lastRefusalExplanation`. The assistant frame's
// stop_details is the primary source for that explanation —
// this structured banner is the backup source when the frame
// carried none (older CLIs, gateways that drop stop_details).
if (!lastRefusalExplanation) {
lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
}
break;
default:
unreachable(message, this.logger);
break;
@@ -3057,6 +3096,37 @@ export class ClaudeAcpAgent {
}
}
/** Reconcile adapter model state after the SDK persistently swapped the
* session's model out from under us (refusal fallback). The SDK already
* made the switch, so this must NOT call `query.setModel` — it only
* updates our bookkeeping (currentModelId, context window, mode clamping,
* effort/Fast-mode options) via the same `applyConfigOptionValue` path a
* user-driven model change takes, then notifies the client. */
private async syncModelAfterRefusalFallback(
sessionId: string,
session: Session,
fallbackModel: string,
): Promise<void> {
// Map the SDK-reported model onto one of the session's model options
// (handles display names and `resolvedModel` ids). The fallback model may
// not be among the options — e.g. excluded by the user's
// `availableModels` allowlist — in which case we track the raw id: the
// picker shows no selection, but the model-dependent bookkeeping and any
// later `setModel` round-trip stay truthful to what the SDK is running.
const resolved = resolveModelPreference(session.modelInfos, fallbackModel);
const value = resolved?.value ?? fallbackModel;
if (session.models.currentModelId === value) return;
await this.applyConfigOptionValue(sessionId, session, MODEL_CONFIG_ID, value);
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "config_option_update",
configOptions: session.configOptions,
},
});
}
/** Replace the Fast mode option in `session.configOptions` so it reflects
* `enabled` (and the client's current boolean-capability). A no-op when the
* option isn't present, so callers must confirm the current model surfaces

View File

@@ -2732,6 +2732,227 @@ describe("stop reason propagation", () => {
});
});
describe("model refusal fallback handling", () => {
/** Session overrides with a populated model picker: Fable selected,
* Opus available as the refusal-fallback target. */
const modelStateOverrides = {
models: {
currentModelId: "claude-fable-5",
availableModels: [
{ modelId: "claude-fable-5", name: "Claude Fable 5" },
{ modelId: "claude-opus-4-8", name: "Claude Opus 4.8" },
],
},
modelInfos: [
{ value: "claude-fable-5", displayName: "Claude Fable 5", description: "" },
{ value: "claude-opus-4-8", displayName: "Claude Opus 4.8", description: "" },
],
};
function refusalFallbackMessage(overrides: Record<string, unknown> = {}) {
return {
type: "system",
subtype: "model_refusal_fallback",
trigger: "refusal",
direction: "retry",
original_model: "claude-fable-5",
fallback_model: "claude-opus-4-8",
request_id: "req_1",
api_refusal_category: "cyber",
content: "banner",
uuid: randomUUID(),
session_id: "test-session",
...overrides,
};
}
function successResult() {
return {
type: "result" as const,
subtype: "success" as const,
stop_reason: null,
is_error: false,
result: "",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
/** Replays the prompt's user echo (so the turn activates), then the given
* messages, then settles the turn. */
function makeGenerator(messages: unknown[]) {
return async function* (input: Pushable<any>) {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
}
yield* messages as any;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
};
}
function createCapturingAgent() {
const sessionUpdate = vi.fn(async () => {});
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
log: () => {},
error: () => {},
});
return { agent, sessionUpdate };
}
it("notifies the user and reconciles model state on model_refusal_fallback", async () => {
const { agent, sessionUpdate } = createCapturingAgent();
injectGeneratorSession(
agent,
makeGenerator([refusalFallbackMessage(), successResult()]),
modelStateOverrides,
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
const session = agent.sessions["test-session"];
// The swap is persistent — our bookkeeping must follow it.
expect(session.models.currentModelId).toBe("claude-opus-4-8");
// The SDK made the switch itself; a setModel round-trip would be wrong.
expect(session.query.setModel).not.toHaveBeenCalled();
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
const notice = updates.find(
(u) => u.sessionUpdate === "agent_message_chunk" && u.content.text.includes("Model fallback"),
);
expect(notice).toBeDefined();
expect(notice.content.text).toContain("claude-fable-5");
expect(notice.content.text).toContain("claude-opus-4-8");
expect(notice.content.text).toContain("(cyber)");
const configUpdate = updates.find((u) => u.sessionUpdate === "config_option_update");
expect(configUpdate).toBeDefined();
const modelOption = configUpdate.configOptions.find((o: { id: string }) => o.id === "model");
expect(modelOption.currentValue).toBe("claude-opus-4-8");
});
it("includes the refusal explanation in the fallback notice when present", async () => {
const { agent, sessionUpdate } = createCapturingAgent();
injectGeneratorSession(
agent,
makeGenerator([
refusalFallbackMessage({
api_refusal_explanation: "This request tripped a safety classifier.",
}),
successResult(),
]),
modelStateOverrides,
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
const notice = updates.find(
(u) => u.sessionUpdate === "agent_message_chunk" && u.content.text.includes("Model fallback"),
);
expect(notice.content.text).toContain("This request tripped a safety classifier.");
});
it("tracks the raw model id when the fallback model is not among the options", async () => {
const { agent, sessionUpdate } = createCapturingAgent();
injectGeneratorSession(
agent,
makeGenerator([
refusalFallbackMessage({ fallback_model: "claude-mystery-9" }),
successResult(),
]),
modelStateOverrides,
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const session = agent.sessions["test-session"];
// Not resolvable to an option — keep the truthful raw id anyway so
// model-dependent bookkeeping doesn't keep advertising the refused model.
expect(session.models.currentModelId).toBe("claude-mystery-9");
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
expect(updates.some((u) => u.sessionUpdate === "config_option_update")).toBe(true);
});
it("skips the config update when the fallback equals the tracked model", async () => {
const { agent, sessionUpdate } = createCapturingAgent();
injectGeneratorSession(
agent,
makeGenerator([
refusalFallbackMessage({ fallback_model: "claude-fable-5" }),
successResult(),
]),
modelStateOverrides,
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
// Notice still shown, but no state churn.
expect(updates.some((u) => u.sessionUpdate === "agent_message_chunk")).toBe(true);
expect(updates.some((u) => u.sessionUpdate === "config_option_update")).toBe(false);
});
it("surfaces the structured explanation when a refusal has no fallback", async () => {
const { agent, sessionUpdate } = createCapturingAgent();
injectGeneratorSession(
agent,
makeGenerator([
{
type: "system",
subtype: "model_refusal_no_fallback",
original_model: "claude-fable-5",
request_id: "req_1",
api_refusal_category: "cyber",
api_refusal_explanation: "Declined by safety classifiers.",
content: "banner",
uuid: randomUUID(),
session_id: "test-session",
},
{ ...successResult(), stop_reason: "refusal" },
]),
modelStateOverrides,
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("refusal");
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
const chunk = updates.find((u) => u.sessionUpdate === "agent_message_chunk");
expect(chunk.content.text).toBe("Declined by safety classifiers.");
// No model reconciliation on the no-fallback path.
expect(updates.some((u) => u.sessionUpdate === "config_option_update")).toBe(false);
});
});
describe("logout", () => {
function createMockAgent() {
const mockClient = {