From 15dfd83daa84ced98ecbaa7c83fd5a53124030c1 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 2 Jul 2026 12:14:50 +0200 Subject: [PATCH] feat: Add refusal fallback consent dialog support (#834) --- src/acp-agent.ts | 136 +++++++++++++++++--- src/elicitation.ts | 105 ++++++++++++++++ src/tests/acp-agent.test.ts | 153 ++++++++++++++++++++++- src/tests/create-session-options.test.ts | 123 ++++++++++++++++++ src/tests/elicitation.test.ts | 101 +++++++++++++++ 5 files changed, 599 insertions(+), 19 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index d94122cf..a1cc80ed 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -59,6 +59,7 @@ import { ModelInfo, ModelUsage, OnElicitation, + OnUserDialog, Options, PermissionMode, PermissionResult, @@ -90,7 +91,11 @@ import { createElicitationResponseToElicitResult, ElicitationSupport, extractAskUserQuestions, + extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, + REFUSAL_FALLBACK_DIALOG_KIND, + refusalFallbackResultFromResponse, + refusalFallbackToCreateRequest, } from "./elicitation.js"; import { SettingsManager } from "./settings.js"; import { @@ -1622,27 +1627,39 @@ export class ClaudeAcpAgent { // client's model picker (and the model-dependent options // rebuilt from it) keeps advertising a model the session is no // longer running. + // + // Current CLIs only emit direction "retry" (persistent swap). + // "revert"/"sticky" are retained in the SDK enum for older + // CLIs, where "revert" marked a turn-only fallback — for that + // direction the session stays on the original model, so skip + // the persistent-swap claim and the state sync. + const persistent = message.direction !== "revert"; const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : ""; const explanation = message.api_refusal_explanation ? `\n\n${message.api_refusal_explanation}` : ""; + const outcome = persistent + ? `The session will continue on ${message.fallback_model}.` + : `The session stays on ${message.original_model}.`; 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}`, + text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`, }, }, }); - await this.syncModelAfterRefusalFallback( - params.sessionId, - session, - message.fallback_model, - ); + if (persistent) { + await this.syncModelAfterRefusalFallback( + params.sessionId, + session, + message.fallback_model, + ); + } break; } case "model_refusal_no_fallback": @@ -1652,7 +1669,15 @@ export class ClaudeAcpAgent { // 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) { + // + // `refused_user_message_uuid` is explicitly null when the + // refused turn was not human-authored (a background + // task-notification followup or auto-continuation) — don't + // let those pollute the user turn's explanation. `undefined` + // (older CLIs that omit the field) can't be attributed either + // way, so keep seeding — the same exposure the assistant-frame + // capture already has. + if (!lastRefusalExplanation && message.refused_user_message_uuid !== null) { lastRefusalExplanation = message.api_refusal_explanation ?? message.content; } break; @@ -2035,7 +2060,13 @@ export class ClaudeAcpAgent { lastAssistantError = message.error; } if (message.message.stop_reason === "refusal") { - lastRefusalExplanation = message.message.stop_details?.explanation ?? null; + // Keep any explanation already seeded by a + // `model_refusal_no_fallback` banner — the banner/frame + // ordering is CLI-dependent, and a frame whose stop_details + // was dropped (the case the banner backup exists for) must + // not clobber the seed back to null. + lastRefusalExplanation = + message.message.stop_details?.explanation ?? lastRefusalExplanation; } } @@ -2933,6 +2964,49 @@ export class ClaudeAcpAgent { return { behavior: "allow", updatedInput: outcome.updatedInput }; } + /** + * Handle `request_user_dialog` control requests — blocking dialogs the CLI + * asks the host to render. Only kinds declared in `supportedDialogKinds` + * are ever emitted; everything unexpected is answered `cancelled` (the + * required answer for unrecognized kinds), which applies the dialog's + * default behavior CLI-side. Today the only declared kind is the + * refusal-fallback consent prompt, rendered as an ACP form elicitation. + */ + private handleUserDialog(sessionId: string): OnUserDialog { + return async (request, { signal }) => { + if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) { + return { behavior: "cancelled" }; + } + const prompt = extractRefusalFallbackPrompt(request.payload); + if (!prompt) { + this.logger.error( + `refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`, + ); + return { behavior: "cancelled" }; + } + let response: CreateElicitationResponse; + try { + response = await this.client.unstable_createElicitation( + refusalFallbackToCreateRequest(prompt, sessionId), + signal, + ); + } catch (error) { + // A cancellation we requested (signal aborted) is expected teardown; + // anything else is a client failure. Either way the safe answer is + // `cancelled` — the CLI applies the dialog's default (keep the + // refusal) rather than switching models without consent. + if (!signal.aborted) { + this.logger.error(`Failed to present refusal fallback elicitation: ${error}`); + } + return { behavior: "cancelled" }; + } + if (signal.aborted) { + return { behavior: "cancelled" }; + } + return { behavior: "completed", result: refusalFallbackResultFromResponse(response) }; + }; + } + private async sendAvailableCommandsUpdate(sessionId: string): Promise { const session = this.sessions[sessionId]; if (!session) return; @@ -2997,8 +3071,16 @@ export class ClaudeAcpAgent { session.models = { ...session.models, currentModelId: value }; // Recompute availableModes for the new model and clamp the current - // mode if the SDK no longer offers it (today: "auto" on Haiku). - const newAvailableModes = buildAvailableModes(newModelInfo); + // mode if the SDK no longer offers it (today: "auto" on Haiku). An + // unknown model (an SDK-initiated refusal fallback to a model outside + // the user's `availableModels` allowlist — user-driven switches are + // validated against the options first) tells us nothing about its + // capabilities, so keep the current modes rather than spuriously + // downgrading (e.g. kicking the user out of "auto" for a model that + // does support it). + const newAvailableModes = newModelInfo + ? buildAvailableModes(newModelInfo) + : session.modes.availableModes; // Capture BEFORE mutating session.modes so the log message reflects // the invalidated mode rather than "default". const previousModeId = session.modes.currentModeId; @@ -3117,14 +3199,20 @@ export class ClaudeAcpAgent { 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, - }, - }); + try { + await this.updateConfigOption(sessionId, MODEL_CONFIG_ID, value); + } catch (err) { + // This runs on the consumer loop: a throw here tears down the query + // stream (failAllTurns + closeQueryStream) and bricks the session — + // far worse than stale bookkeeping. The user-driven RPC path lets the + // same errors propagate to fail just that request; here we log and + // move on, matching the setPermissionMode containment inside + // applyConfigOptionValue. + this.logger.error( + `Failed to reconcile model state after refusal fallback to "${fallbackModel}":`, + err, + ); + } } /** Replace the Fast mode option in `session.configOptions` so it reflects @@ -3427,6 +3515,18 @@ export class ClaudeAcpAgent { ...(elicitationSupport.form || elicitationSupport.url ? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) } : {}), + // Render the CLI's refusal-fallback consent prompt (" declined — + // retry with ?") as an ACP form elicitation. Declaring the + // kind is the opt-in: the CLI never emits an undeclared dialog, and the + // flow instead degrades to the classic refusal error ending the turn. + // Gated on form elicitation since that's the only ACP surface that can + // present a choice outside a tool call. + ...(elicitationSupport.form + ? { + onUserDialog: this.handleUserDialog(sessionId), + supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND], + } + : {}), pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()), extraArgs: { ...userProvidedOptions?.extraArgs, diff --git a/src/elicitation.ts b/src/elicitation.ts index a7146f01..43a3a8ed 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -298,3 +298,108 @@ function normalizeElicitationSchema( } return { ...(schema as ElicitationSchema), type: "object" }; } + +/** + * The `request_user_dialog` kind the CLI emits when a model refusal has a + * fallback available but needs user consent before retrying (e.g. Claude Fable + * declining a request with Opus available as the fallback). Declaring this + * kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never + * emits an undeclared kind — the flow degrades to the classic refusal error + * ending the turn. + */ +export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt"; + +/** + * Payload of the `refusal_fallback_prompt` dialog. The dialog protocol + * transports payloads opaquely, so this shape is recovered from the CLI's own + * schema (v2.1.177): `originalModel`/`fallbackModel` are required strings; + * `apiRefusalCategory` (nullable), `guidanceText`, and + * `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` — + * ACP has no way to retract already-streamed chunks. + */ +export type RefusalFallbackPrompt = { + originalModel: string; + fallbackModel: string; + apiRefusalCategory: string | null; + guidanceText?: string; +}; + +/** + * Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}. + * Returns `null` when the required fields are missing or mistyped (a newer CLI + * may reshape the payload), so the caller can cancel the dialog and let the + * CLI apply its default behavior instead of rendering something misleading. + */ +export function extractRefusalFallbackPrompt( + payload: Record, +): RefusalFallbackPrompt | null { + const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload; + if (typeof originalModel !== "string" || typeof fallbackModel !== "string") { + return null; + } + return { + originalModel, + fallbackModel, + apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null, + ...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}), + }; +} + +/** Form-field key carrying the user's choice in the refusal-fallback form. */ +const REFUSAL_FALLBACK_CHOICE_KEY = "choice"; + +/** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is + * deliberately not offered: in the CLI it prefills the composer with the + * refused prompt for edit-and-retry, and ACP has no composer-prefill surface + * — the user can simply edit and resend on their own. */ +const RETRY_FALLBACK_RESULT = "retry_fallback"; +const KEEP_REFUSAL_RESULT = "cancelled"; + +/** + * Render the refusal-fallback consent prompt as an ACP form elicitation: a + * single-select between retrying on the fallback model and keeping the + * refusal. The enum `const`s are the dialog's wire result values, so the + * response maps back without a translation table. + */ +export function refusalFallbackToCreateRequest( + prompt: RefusalFallbackPrompt, + sessionId: string, +): CreateElicitationRequest { + const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : ""; + const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : ""; + return { + mode: "form", + sessionId, + message: + `${prompt.originalModel} declined this request${category}. ` + + `Retry with ${prompt.fallbackModel}? The session would continue on ${prompt.fallbackModel}.` + + guidance, + requestedSchema: { + type: "object", + properties: { + [REFUSAL_FALLBACK_CHOICE_KEY]: { + type: "string", + oneOf: [ + { const: RETRY_FALLBACK_RESULT, title: `Retry with ${prompt.fallbackModel}` }, + { const: KEEP_REFUSAL_RESULT, title: "Keep the refusal" }, + ], + }, + }, + }, + }; +} + +/** + * Map the elicitation response back to the dialog's result enum. Only an + * explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a + * skipped field, or an unrecognized value all keep the refusal — the dialog's + * own default — so a dismissed or half-filled form can never trigger a model + * switch the user didn't ask for. + */ +export function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string { + if (response.action !== "accept") { + return KEEP_REFUSAL_RESULT; + } + const choice = response.content?.[REFUSAL_FALLBACK_CHOICE_KEY]; + return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT; +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 3c10faea..f45119e7 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -2734,7 +2734,9 @@ 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. */ + * Opus available as the refusal-fallback target. `modes` mirrors a real + * session (never empty), so the mode-clamp logic in applyConfigOptionValue + * sees realistic state. */ const modelStateOverrides = { models: { currentModelId: "claude-fable-5", @@ -2747,6 +2749,13 @@ describe("model refusal fallback handling", () => { { value: "claude-fable-5", displayName: "Claude Fable 5", description: "" }, { value: "claude-opus-4-8", displayName: "Claude Opus 4.8", description: "" }, ], + modes: { + currentModeId: "default", + availableModes: [ + { id: "auto", name: "Auto", description: "" }, + { id: "default", name: "Default", description: "" }, + ], + }, }; function refusalFallbackMessage(overrides: Record = {}) { @@ -2951,6 +2960,148 @@ describe("model refusal fallback handling", () => { // No model reconciliation on the no-fallback path. expect(updates.some((u) => u.sessionUpdate === "config_option_update")).toBe(false); }); + + it("does not persist the swap for a turn-only fallback (direction revert)", async () => { + const { agent, sessionUpdate } = createCapturingAgent(); + injectGeneratorSession( + agent, + makeGenerator([refusalFallbackMessage({ direction: "revert" }), successResult()]), + modelStateOverrides, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const session = agent.sessions["test-session"]; + // Older-CLI "revert" means the session stays on the original model. + expect(session.models.currentModelId).toBe("claude-fable-5"); + 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("stays on claude-fable-5"); + expect(updates.some((u) => u.sessionUpdate === "config_option_update")).toBe(false); + }); + + it("keeps the current permission modes when the fallback model is unknown", async () => { + const { agent, sessionUpdate } = createCapturingAgent(); + injectGeneratorSession( + agent, + makeGenerator([ + refusalFallbackMessage({ fallback_model: "claude-mystery-9" }), + successResult(), + ]), + { + ...modelStateOverrides, + // Session is running in auto mode; the unknown fallback model's + // capabilities are unknowable, so the mode must NOT be clamped. + modes: { + currentModeId: "auto", + availableModes: [ + { id: "auto", name: "Auto", description: "" }, + { id: "default", name: "Default", description: "" }, + ], + }, + }, + ); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const session = agent.sessions["test-session"]; + expect(session.models.currentModelId).toBe("claude-mystery-9"); + expect(session.modes.currentModeId).toBe("auto"); + const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update); + expect(updates.some((u) => u.sessionUpdate === "current_mode_update")).toBe(false); + }); + + it("keeps the banner explanation when the refusal frame arrives after it without stop_details", 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_explanation: "Declined by safety classifiers.", + content: "banner", + uuid: randomUUID(), + session_id: "test-session", + }, + // The consolidated refusal frame from a gateway that dropped + // stop_details — it must not clobber the banner's explanation. + { + type: "assistant", + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + role: "assistant", + model: "claude-fable-5", + stop_reason: "refusal", + content: [], + usage: { + input_tokens: 10, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }, + { ...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" && + u.content.text === "Declined by safety classifiers.", + ); + expect(chunk).toBeDefined(); + }); + + it("ignores a non-human-authored refusal banner (refused_user_message_uuid null)", 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_explanation: "Background task declined.", + refused_user_message_uuid: null, + content: "banner", + uuid: randomUUID(), + session_id: "test-session", + }, + { ...successResult(), stop_reason: "refusal" }, + ]), + 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); + // The background followup's explanation must not be attributed to the + // user's turn. + expect( + updates.some( + (u) => + u.sessionUpdate === "agent_message_chunk" && + u.content.text.includes("Background task declined."), + ), + ).toBe(false); + }); }); describe("logout", () => { diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 468f43c5..18080545 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -613,4 +613,127 @@ describe("createSession options merging", () => { expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion"); }); }); + + describe("refusal fallback dialog", () => { + it("declares the dialog kind and callback when the client supports form elicitation", async () => { + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} } }, + }); + await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); + + expect(capturedOptions!.supportedDialogKinds).toEqual(["refusal_fallback_prompt"]); + expect(typeof capturedOptions!.onUserDialog).toBe("function"); + }); + + it("omits the dialog wiring without form elicitation support (url-only included)", async () => { + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { url: {} } }, + }); + await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); + + // The CLI fails closed on the undeclared kind, so the flow degrades to + // the classic refusal error instead of parking a dialog we can't render. + expect(capturedOptions!.supportedDialogKinds).toBeUndefined(); + expect(capturedOptions!.onUserDialog).toBeUndefined(); + }); + + /** Wire the session up with form support and return the captured dialog + * callback plus a mock the test can point the elicitation response at. */ + async function setupDialog() { + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { elicitation: { form: {} } }, + }); + await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); + const createElicitation = vi.fn(); + ( + agent as unknown as { client: { unstable_createElicitation: unknown } } + ).client.unstable_createElicitation = createElicitation; + return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation }; + } + + const signal = () => ({ signal: new AbortController().signal }); + + it("renders the prompt as a form elicitation and maps the retry choice", async () => { + const { onUserDialog, createElicitation } = await setupDialog(); + createElicitation.mockResolvedValue({ + action: "accept", + content: { choice: "retry_fallback" }, + }); + + const result = await onUserDialog( + { + dialogKind: "refusal_fallback_prompt", + payload: { + originalModel: "claude-fable-5", + fallbackModel: "claude-opus-4-8", + apiRefusalCategory: "cyber", + }, + }, + signal(), + ); + + expect(result).toEqual({ behavior: "completed", result: "retry_fallback" }); + const request = createElicitation.mock.calls[0][0]; + expect(request.mode).toBe("form"); + expect(request.message).toContain("claude-fable-5"); + expect(request.message).toContain("claude-opus-4-8"); + }); + + it("completes with cancelled when the user keeps the refusal", async () => { + const { onUserDialog, createElicitation } = await setupDialog(); + createElicitation.mockResolvedValue({ action: "cancel" }); + + const result = await onUserDialog( + { + dialogKind: "refusal_fallback_prompt", + payload: { originalModel: "a", fallbackModel: "b" }, + }, + signal(), + ); + + expect(result).toEqual({ behavior: "completed", result: "cancelled" }); + }); + + it("cancels unrecognized dialog kinds without presenting anything", async () => { + const { onUserDialog, createElicitation } = await setupDialog(); + + const result = await onUserDialog( + { dialogKind: "some_future_dialog", payload: {} }, + signal(), + ); + + expect(result).toEqual({ behavior: "cancelled" }); + expect(createElicitation).not.toHaveBeenCalled(); + }); + + it("cancels on a malformed payload without presenting anything", async () => { + const { onUserDialog, createElicitation } = await setupDialog(); + + const result = await onUserDialog( + { dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } }, + signal(), + ); + + expect(result).toEqual({ behavior: "cancelled" }); + expect(createElicitation).not.toHaveBeenCalled(); + }); + + it("cancels when the elicitation request fails", async () => { + const { onUserDialog, createElicitation } = await setupDialog(); + createElicitation.mockRejectedValue(new Error("client exploded")); + + const result = await onUserDialog( + { + dialogKind: "refusal_fallback_prompt", + payload: { originalModel: "a", fallbackModel: "b" }, + }, + signal(), + ); + + expect(result).toEqual({ behavior: "cancelled" }); + }); + }); }); diff --git a/src/tests/elicitation.test.ts b/src/tests/elicitation.test.ts index f81fee6a..128b031c 100644 --- a/src/tests/elicitation.test.ts +++ b/src/tests/elicitation.test.ts @@ -11,7 +11,10 @@ import { AskUserQuestion, createElicitationResponseToElicitResult, extractAskUserQuestions, + extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, + refusalFallbackResultFromResponse, + refusalFallbackToCreateRequest, } from "../elicitation.js"; const SESSION_ID = "session-1"; @@ -389,3 +392,101 @@ describe("applyAskElicitationResponse", () => { ); }); }); + +describe("extractRefusalFallbackPrompt", () => { + it("extracts required and optional fields", () => { + expect( + extractRefusalFallbackPrompt({ + originalModel: "claude-fable-5", + fallbackModel: "claude-opus-4-8", + apiRefusalCategory: "cyber", + guidanceText: "You can retry with the fallback model.", + retractedMessageUuids: ["u1"], + }), + ).toEqual({ + originalModel: "claude-fable-5", + fallbackModel: "claude-opus-4-8", + apiRefusalCategory: "cyber", + guidanceText: "You can retry with the fallback model.", + }); + }); + + it("returns null when a required model field is missing or mistyped", () => { + expect(extractRefusalFallbackPrompt({ fallbackModel: "claude-opus-4-8" })).toBeNull(); + expect( + extractRefusalFallbackPrompt({ originalModel: 5, fallbackModel: "claude-opus-4-8" }), + ).toBeNull(); + }); + + it("normalizes a non-string category to null and drops empty guidance", () => { + expect( + extractRefusalFallbackPrompt({ + originalModel: "a", + fallbackModel: "b", + apiRefusalCategory: null, + guidanceText: "", + }), + ).toEqual({ originalModel: "a", fallbackModel: "b", apiRefusalCategory: null }); + }); +}); + +describe("refusalFallbackToCreateRequest", () => { + const prompt = { + originalModel: "claude-fable-5", + fallbackModel: "claude-opus-4-8", + apiRefusalCategory: "cyber" as string | null, + }; + + it("builds a single-choice form whose enum consts are the dialog's wire results", () => { + const request = refusalFallbackToCreateRequest(prompt, SESSION_ID) as Extract< + CreateElicitationRequest, + { mode: "form" } + >; + expect(request.mode).toBe("form"); + expect(request).toMatchObject({ sessionId: SESSION_ID }); + expect(request.message).toContain("claude-fable-5"); + expect(request.message).toContain("(cyber)"); + expect(request.message).toContain("claude-opus-4-8"); + + const choice = request.requestedSchema.properties?.choice as { + type: string; + oneOf: Array<{ const: string; title: string }>; + }; + expect(choice.type).toBe("string"); + expect(choice.oneOf.map((o) => o.const)).toEqual(["retry_fallback", "cancelled"]); + expect(choice.oneOf[0].title).toContain("claude-opus-4-8"); + }); + + it("omits the category marker and appends guidance when provided", () => { + const request = refusalFallbackToCreateRequest( + { ...prompt, apiRefusalCategory: null, guidanceText: "Consider rephrasing." }, + SESSION_ID, + ); + expect(request.message).not.toContain("("); + expect(request.message).toContain("Consider rephrasing."); + }); +}); + +describe("refusalFallbackResultFromResponse", () => { + it("maps an accepted retry choice to retry_fallback", () => { + const response = { + action: "accept", + content: { choice: "retry_fallback" }, + } as CreateElicitationResponse; + expect(refusalFallbackResultFromResponse(response)).toBe("retry_fallback"); + }); + + it("keeps the refusal for every other outcome", () => { + const cases = [ + { action: "accept", content: { choice: "cancelled" } }, + { action: "accept", content: {} }, + { action: "accept", content: { choice: "something-new" } }, + { action: "accept" }, + { action: "decline" }, + { action: "cancel" }, + ] as CreateElicitationResponse[]; + for (const response of cases) { + expect(refusalFallbackResultFromResponse(response)).toBe("cancelled"); + } + }); +});