mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: Add refusal fallback consent dialog support (#834)
This commit is contained in:
136
src/acp-agent.ts
136
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<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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user