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:
uky333
2026-06-20 14:24:06 +08:00
committed by GitHub
parent 631b7806ed
commit eb68198c42
5 changed files with 83 additions and 2 deletions
+1 -1
View File
@@ -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)]