feat: Add refusal fallback consent dialog support (#834)

This commit is contained in:
Ben Brandt
2026-07-02 12:14:50 +02:00
committed by GitHub
parent 648e3f60bd
commit 15dfd83daa
5 changed files with 599 additions and 19 deletions

View File

@@ -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<void> {
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 ("<model> declined —
// retry with <fallback>?") 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,

View File

@@ -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<string, unknown>,
): 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;
}

View File

@@ -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<string, unknown> = {}) {
@@ -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", () => {

View File

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

View File

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