diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 60cf1279..5314f7b1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -64,7 +64,7 @@ export interface AdapterRuntimeServiceReport { healthStatus?: "unknown" | "healthy" | "unhealthy"; } -export type AdapterExecutionErrorFamily = "transient_upstream"; +export type AdapterExecutionErrorFamily = "transient_upstream" | "model_refusal"; export interface AdapterExecutionResult { exitCode: number | null; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a2656061..d238bbe6 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -52,6 +52,7 @@ import { detectClaudeLoginRequired, extractClaudeRetryNotBefore, isClaudeMaxTurnsResult, + isClaudeRefusalResult, isClaudeTransientUpstreamError, isClaudeUnknownSessionError, isClaudePoisonedPreviousMessageIdError, @@ -877,6 +878,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...parsed, ...(failed && clearSessionForMaxTurns ? { stopReason: "max_turns_exhausted" } : {}), ...(failed && poisonedPreviousMessageId ? { stopReason: "claude_poisoned_previous_message_id" } : {}), + ...(claudeRefusal ? { stopReason: "refusal", errorFamily: "model_refusal" } : {}), ...(transientUpstream ? { errorFamily: "transient_upstream" } : {}), ...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}), ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), @@ -949,7 +957,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }); }); +describe("isClaudeRefusalResult", () => { + it("detects stop_reason: refusal even on a clean (is_error=false) result", () => { + expect( + isClaudeRefusalResult({ + type: "result", + subtype: "success", + is_error: false, + stop_reason: "refusal", + result: "", + }), + ).toBe(true); + }); + + it("detects the camelCase stopReason variant", () => { + expect(isClaudeRefusalResult({ stopReason: "refusal" })).toBe(true); + }); + + it("detects subtype: model_refusal", () => { + expect( + isClaudeRefusalResult({ subtype: "model_refusal", is_error: false }), + ).toBe(true); + }); + + it("is case-insensitive and tolerant of surrounding whitespace", () => { + expect(isClaudeRefusalResult({ stop_reason: " Refusal " })).toBe(true); + }); + + it("returns false for ordinary successful turns", () => { + expect( + isClaudeRefusalResult({ + subtype: "success", + is_error: false, + stop_reason: "end_turn", + result: "Here is your answer.", + }), + ).toBe(false); + }); + + it("returns false for max-turns and other stop reasons", () => { + expect(isClaudeRefusalResult({ stop_reason: "max_turns" })).toBe(false); + expect(isClaudeRefusalResult({ subtype: "error_max_turns" })).toBe(false); + }); + + it("returns false for null/empty parsed result", () => { + expect(isClaudeRefusalResult(null)).toBe(false); + expect(isClaudeRefusalResult({})).toBe(false); + }); +}); + describe("isClaudeUnknownSessionError", () => { it("detects the legacy 'no conversation found' message", () => { expect( diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index fbf66f79..14c85534 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -185,6 +185,24 @@ export function isClaudeMaxTurnsResult(parsed: Record | null | ); } +export function isClaudeRefusalResult(parsed: Record | null | undefined): boolean { + if (!parsed) return false; + + // A policy refusal exits the CLI cleanly (exitCode=0, is_error=false), so it + // must be detected from the structured fields rather than the failure flag. + const subtype = asString(parsed.subtype, "").trim().toLowerCase(); + if (subtype === "model_refusal" || subtype === "refusal") return true; + + const structuredStopReasons = [ + parsed.stop_reason, + parsed.stopReason, + parsed.error_code, + parsed.errorCode, + ].map((value) => asString(value, "").trim().toLowerCase()); + + return structuredStopReasons.some((reason) => reason === "refusal"); +} + export function isClaudeUnknownSessionError(parsed: Record): boolean { const resultText = asString(parsed.result, "").trim(); const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]