From eb68198c427c401783780cde446760c09b776af0 Mon Sep 17 00:00:00 2001 From: uky333 <87807117+uky333@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:24:06 +0800 Subject: [PATCH] feat(adapter-claude-local): emit errorCode claude_refusal on stop_reason refusal (#8314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through adapters; `@paperclipai/adapter-claude-local` shells out to the Claude CLI and maps its result JSON into Paperclip's run outcome (`errorCode` / `errorFamily`) > - A Fable 5 policy refusal exits the CLI cleanly (`exitCode=0`, `is_error=false`) with `stop_reason: "refusal"`, which the adapter mapped to `errorCode: null` > - Paperclip therefore recorded the run as a silent success, so the agent's heartbeat stalled with no signal for operators and no hook to retry or alert > - This pull request adds a refusal detector to the adapter so a refusal resolves to a distinct `errorCode: "claude_refusal"` (`errorFamily: "model_refusal"`) > - The benefit is that policy refusals become observable: the server persists the code, operators can see it on the run, and downstream retry/alert policy can key off it ## Linked Issues or Issue Description No public GitHub issue exists. Describing the underlying bug in-PR, following `.github/ISSUE_TEMPLATE/bug_report.yml`: **What happened?** When the Claude CLI returns `stop_reason: "refusal"` (a model policy refusal), it still exits cleanly (`exitCode=0`, `is_error=false`). `@paperclipai/adapter-claude-local` keyed refusal detection off the failure flag during error-code resolution, so it returned `errorCode: null` — a refused run was indistinguishable from a successful one, recorded by Paperclip as a silent success with no signal to retry or alert. **Expected behavior** A refusal should resolve to a distinct, non-null error code (`errorCode: "claude_refusal"`, `errorFamily: "model_refusal"`) so the server can surface it to operators and downstream policy can react. **Steps to reproduce** 1. Run any agent on the `claude-local` adapter with a prompt the model refuses on policy grounds. 2. The Claude CLI exits `0` with `is_error=false` and `stop_reason: "refusal"`. 3. Observe the run resolves `errorCode: null` (pre-fix) instead of a refusal-specific code. **Paperclip version or commit** `adapter-claude-local` v2026.609.0 (`dist/server/execute.js` error-code resolution block); reproduced on `master`. **Deployment mode** Local / self-hosted. **Agent adapter(s) involved** `@paperclipai/adapter-claude-local` (Claude Code, local). ## What Changed - **`packages/adapters/claude-local/src/server/parse.ts`** — new `isClaudeRefusalResult()` helper, parallel to `isClaudeMaxTurnsResult()`. Detects `stop_reason` / `stopReason` / `error_code` / `errorCode` == `refusal` and `subtype` == `model_refusal`, case/whitespace tolerant. - **`packages/adapters/claude-local/src/server/execute.ts`** — compute the refusal flag independent of the `failed` flag (a refusal exits cleanly, so keying off `failed` would miss it); resolve `errorCode: "claude_refusal"` and `errorFamily: "model_refusal"`; surface `stopReason: "refusal"` in `resultJson`. - **`packages/adapter-utils/src/types.ts`** — widen `AdapterExecutionErrorFamily` with `"model_refusal"`. - **`packages/adapters/claude-local/src/server/index.ts`** — export the new helper. - **`packages/adapters/claude-local/src/server/parse.test.ts`** — 7 new unit tests. ## Verification ``` npx vitest run packages/adapters/claude-local # 35 passed (7 new) npx tsc --noEmit -p packages/adapter-utils # clean npx tsc --noEmit -p packages/adapters/claude-local # clean ``` Manual: a result JSON with `stop_reason: "refusal"` on a clean exit now resolves `errorCode: "claude_refusal"` and `errorFamily: "model_refusal"`; non-refusal results are unaffected. ## Risks Low risk. Additive only — it introduces a new error code on a path that previously returned `null`; no existing error code or success path changes. `claude_refusal` is deliberately **not** added to the transient-upstream retry set: a refusal is deterministic, so retrying the same prompt yields the same refusal. It surfaces as a distinct non-transient code for operators; downstream retry/alert policy can key off `claude_refusal` / `errorFamily: model_refusal` later. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`), extended-thinking mode, run via Claude Code with tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above (no related PRs found) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (N/A — no UI changes) - [x] I have updated relevant documentation to reflect my changes (N/A — internal adapter error-code addition; no user-facing docs) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (this push re-runs the gates; will confirm once green) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (prior run was 4/5 on a PR-description note now addressed; will confirm on re-run) - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Full-Stack Engineer --- packages/adapter-utils/src/types.ts | 2 +- .../claude-local/src/server/execute.ts | 14 +++++- .../adapters/claude-local/src/server/index.ts | 1 + .../claude-local/src/server/parse.test.ts | 50 +++++++++++++++++++ .../adapters/claude-local/src/server/parse.ts | 18 +++++++ 5 files changed, 83 insertions(+), 2 deletions(-) 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)]