feat(adapter-claude-local): emit errorCode claude_refusal on stop_reason refusal (#8314)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
detectClaudeLoginRequired,
|
||||
extractClaudeRetryNotBefore,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeRefusalResult,
|
||||
isClaudeTransientUpstreamError,
|
||||
isClaudeUnknownSessionError,
|
||||
isClaudePoisonedPreviousMessageIdError,
|
||||
@@ -877,6 +878,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
|
||||
const poisonedPreviousMessageId = isClaudePoisonedPreviousMessageIdError(parsed);
|
||||
// Fable 5 policy refusals exit cleanly (exitCode=0, is_error=false), so this
|
||||
// is intentionally independent of `failed` — otherwise a refusal looks like a
|
||||
// successful run to Paperclip and the heartbeat stalls silently. See RY-604.
|
||||
const claudeRefusal = isClaudeRefusalResult(parsed);
|
||||
const parsedIsError = asBoolean(parsed.is_error, false);
|
||||
const failed = (proc.exitCode ?? 0) !== 0 || parsedIsError;
|
||||
// Validate-before-persist guard: never persist a sessionId whose transcript
|
||||
@@ -933,11 +938,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
? "claude_poisoned_previous_message_id"
|
||||
: transientUpstream
|
||||
? "claude_transient_upstream"
|
||||
: claudeRefusal
|
||||
? "claude_refusal"
|
||||
: null;
|
||||
const mergedResultJson: Record<string, unknown> = {
|
||||
...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<AdapterExec
|
||||
timedOut: false,
|
||||
errorMessage,
|
||||
errorCode: resolvedErrorCode,
|
||||
errorFamily: transientUpstream ? "transient_upstream" : null,
|
||||
errorFamily: transientUpstream
|
||||
? "transient_upstream"
|
||||
: claudeRefusal
|
||||
? "model_refusal"
|
||||
: null,
|
||||
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
|
||||
errorMeta,
|
||||
usage,
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeRefusalResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
export {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
extractClaudeRetryNotBefore,
|
||||
isClaudeTransientUpstreamError,
|
||||
isClaudePoisonedPreviousMessageIdError,
|
||||
isClaudeRefusalResult,
|
||||
isClaudeUnknownSessionError,
|
||||
isClaudeImageProcessingError,
|
||||
} from "./parse.js";
|
||||
@@ -146,6 +147,55 @@ describe("isClaudePoisonedPreviousMessageIdError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -185,6 +185,24 @@ export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null |
|
||||
);
|
||||
}
|
||||
|
||||
export function isClaudeRefusalResult(parsed: Record<string, unknown> | 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<string, unknown>): boolean {
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
||||
|
||||
Reference in New Issue
Block a user