Skip to content

Commit 246b0b4

Browse files
authored
feat(agent): add refusal handling and model fallback (#3078)
1 parent 7af30e0 commit 246b0b4

11 files changed

Lines changed: 307 additions & 14 deletions

File tree

packages/agent/src/adapters/claude/UPSTREAM.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
3838
- `SYSTEM_REMINDER` stripping from Read tool results
3939
- WebFetch `resourceLink` content enrichment
4040
- `customTitle` in listSessions (PostHog Code is ahead of upstream here)
41+
- Refusal support: `Options.fallbackModel` defaults to `FALLBACK_MODEL` in
42+
`session/options.ts`; `model_refusal_fallback` system messages emit a
43+
`_posthog/status` notification (`refusal_fallback`) in `sdk-to-acp.ts`; a
44+
terminal `stop_reason: "refusal"` emits `_posthog/status` (`refusal`) instead
45+
of upstream's raw-explanation `agent_message_chunk` (supersedes the v0.42.0
46+
"Refusal handling" port and the v0.44.0 `model_refusal_fallback` skip)
4147
- SettingsManager `PreToolUse` hook for permission rules
4248
- `ensureLocalSettings` / `clearStatsigCache`
4349
- `ELECTRON_RUN_AS_NODE` / `ENABLE_TOOL_SEARCH` env vars

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
517517
let errored = false;
518518
let lastAssistantTotalUsage: number | null = null;
519519
let lastRefusalExplanation: string | null = null;
520+
let lastRefusalCategory: string | null = null;
520521
let lastStreamUsage = {
521522
input_tokens: 0,
522523
output_tokens: 0,
@@ -870,15 +871,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
870871
if (
871872
(message as { stop_reason?: string }).stop_reason === "refusal"
872873
) {
873-
if (lastRefusalExplanation) {
874-
await this.client.sessionUpdate({
875-
sessionId: params.sessionId,
876-
update: {
877-
sessionUpdate: "agent_message_chunk",
878-
content: { type: "text", text: lastRefusalExplanation },
879-
},
880-
});
881-
}
874+
// The API's stop_details.explanation is integrator-facing prose,
875+
// so surface the refusal as a structured status row rather than
876+
// assistant text.
877+
await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, {
878+
sessionId: params.sessionId,
879+
status: "refusal",
880+
...(lastRefusalExplanation && {
881+
explanation: lastRefusalExplanation,
882+
}),
883+
...(lastRefusalCategory && { category: lastRefusalCategory }),
884+
});
882885
return { stopReason: "refusal", usage };
883886
}
884887

@@ -1002,11 +1005,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
10021005
if (message.type === "assistant") {
10031006
const inner = message.message as unknown as {
10041007
stop_reason?: string | null;
1005-
stop_details?: { explanation?: string | null } | null;
1008+
stop_details?: {
1009+
category?: string | null;
1010+
explanation?: string | null;
1011+
} | null;
10061012
};
10071013
if (inner.stop_reason === "refusal") {
10081014
lastRefusalExplanation =
10091015
inner.stop_details?.explanation ?? null;
1016+
lastRefusalCategory = inner.stop_details?.category ?? null;
10101017
}
10111018
}
10121019

packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from "@agentclientprotocol/sdk";
55
import type {
66
SDKAssistantMessage,
7+
SDKModelRefusalFallbackMessage,
78
SDKPartialAssistantMessage,
89
SDKUserMessage,
910
} from "@anthropic-ai/claude-agent-sdk";
@@ -12,6 +13,7 @@ import { Logger } from "../../../utils/logger";
1213
import type { Session } from "../types";
1314
import {
1415
handleStreamEvent,
16+
handleSystemMessage,
1517
handleUserAssistantMessage,
1618
type MessageHandlerContext,
1719
stripMarkerTags,
@@ -64,10 +66,14 @@ describe("stripMarkerTags", () => {
6466

6567
function createHandlerContext() {
6668
const updates: SessionNotification[] = [];
69+
const notifications: Array<{ method: string; params: unknown }> = [];
6770
const client = {
6871
sessionUpdate: async (notification: SessionNotification) => {
6972
updates.push(notification);
7073
},
74+
extNotification: async (method: string, params: unknown) => {
75+
notifications.push({ method, params });
76+
},
7177
} as unknown as AgentSideConnection;
7278
const context: MessageHandlerContext = {
7379
session: {
@@ -86,7 +92,7 @@ function createHandlerContext() {
8692
thinkingIds: new Set(),
8793
},
8894
};
89-
return { context, updates };
95+
return { context, updates, notifications };
9096
}
9197

9298
function streamEvent(
@@ -361,3 +367,72 @@ describe("import replay (no client-side history)", () => {
361367
expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]);
362368
});
363369
});
370+
371+
describe("handleSystemMessage model_refusal_fallback", () => {
372+
function refusalFallbackMessage(
373+
overrides: Partial<SDKModelRefusalFallbackMessage> = {},
374+
): SDKModelRefusalFallbackMessage {
375+
return {
376+
type: "system",
377+
subtype: "model_refusal_fallback",
378+
trigger: "refusal",
379+
direction: "retry",
380+
original_model: "claude-fable-5",
381+
fallback_model: "claude-opus-4-8",
382+
request_id: "req_1",
383+
api_refusal_category: "cyber",
384+
api_refusal_explanation: "This request was declined.",
385+
retracted_message_uuids: [],
386+
content: "Retried on fallback model",
387+
uuid: "00000000-0000-0000-0000-000000000009",
388+
session_id: "test-session",
389+
...overrides,
390+
};
391+
}
392+
393+
it.each<
394+
[string, Partial<SDKModelRefusalFallbackMessage>, Record<string, unknown>]
395+
>([
396+
[
397+
"emits a refusal_fallback status notification with the model swap",
398+
{},
399+
{
400+
sessionId: "test-session",
401+
status: "refusal_fallback",
402+
fromModel: "claude-fable-5",
403+
toModel: "claude-opus-4-8",
404+
explanation: "This request was declined.",
405+
},
406+
],
407+
[
408+
"omits the explanation when the refused response carried none",
409+
{ api_refusal_explanation: null },
410+
{
411+
sessionId: "test-session",
412+
status: "refusal_fallback",
413+
fromModel: "claude-fable-5",
414+
toModel: "claude-opus-4-8",
415+
},
416+
],
417+
])("%s", async (_name, overrides, expectedParams) => {
418+
const { context, updates, notifications } = createHandlerContext();
419+
420+
await handleSystemMessage(refusalFallbackMessage(overrides), context);
421+
422+
expect(updates).toEqual([]);
423+
expect(notifications).toEqual([
424+
{ method: "_posthog/status", params: expectedParams },
425+
]);
426+
});
427+
428+
it.each(["revert", "sticky"] as const)(
429+
"skips the notification for the legacy %s direction",
430+
async (direction) => {
431+
const { context, notifications } = createHandlerContext();
432+
433+
await handleSystemMessage(refusalFallbackMessage({ direction }), context);
434+
435+
expect(notifications).toEqual([]);
436+
},
437+
);
438+
});

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,29 @@ export async function handleSystemMessage(
796796
`Session ${sessionId}: failed to persist history: ${message.error}`,
797797
);
798798
break;
799+
case "model_refusal_fallback": {
800+
logger.info("Refusal retried on fallback model", {
801+
sessionId,
802+
direction: message.direction,
803+
originalModel: message.original_model,
804+
fallbackModel: message.fallback_model,
805+
category: message.api_refusal_category ?? undefined,
806+
requestId: message.request_id ?? undefined,
807+
});
808+
// Only "retry" is emitted live; "revert" and "sticky" are legacy enum
809+
// values whose semantics don't match the "retried with" notice.
810+
if (message.direction !== "retry") break;
811+
await client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, {
812+
sessionId,
813+
status: "refusal_fallback",
814+
fromModel: message.original_model,
815+
toModel: message.fallback_model,
816+
...(message.api_refusal_explanation && {
817+
explanation: message.api_refusal_explanation,
818+
}),
819+
});
820+
break;
821+
}
799822
case "permission_denied": {
800823
const reason = message.decision_reason ?? message.message;
801824
await client.sessionUpdate({

packages/agent/src/adapters/claude/session/models.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { EffortLevel } from "../types";
22

33
export const DEFAULT_MODEL = "opus";
44

5+
// Refusal/overload rescue target. The SDK rejects fallbackModel === Options.model
6+
// at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL.
7+
export const FALLBACK_MODEL = "claude-opus-4-8";
8+
59
// Default thinking level when the user hasn't picked one. Adaptive-only models
610
// like claude-fable-5 reject the SDK's no-effort `thinking: { type: "disabled" }`
711
// shape, so effort-capable models default to high to keep thinking enabled.

packages/agent/src/adapters/claude/session/options.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ describe("buildSessionOptions", () => {
9292
expect(options.agents?.["ph-explore"]).toEqual(override);
9393
});
9494

95+
it.each([
96+
["a new session", () => makeParams()],
97+
["a resumed session", () => ({ ...makeParams(), isResume: true })],
98+
])(
99+
"defaults fallbackModel on %s so refusals and overloads retry on another model",
100+
(_label, params) => {
101+
const options = buildSessionOptions(params());
102+
103+
expect(options.fallbackModel).toBe("claude-opus-4-8");
104+
// The SDK throws at spawn when fallbackModel equals Options.model.
105+
expect(options.fallbackModel).not.toBe(options.model);
106+
},
107+
);
108+
109+
it("preserves a caller-provided fallbackModel", () => {
110+
const options = buildSessionOptions({
111+
...makeParams(),
112+
userProvidedOptions: { fallbackModel: "claude-sonnet-5" },
113+
});
114+
115+
expect(options.fallbackModel).toBe("claude-sonnet-5");
116+
});
117+
95118
it("threads onEnsureLocalToolsConnected into the signed-commit guard (cloud)", async () => {
96119
const healSpy = vi.fn().mockResolvedValue(true);
97120
await runPreToolUseHooks(

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { CodeExecutionMode } from "../tools";
2828
import type { EffortLevel } from "../types";
2929
import { APPENDED_INSTRUCTIONS } from "./instructions";
3030
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
31-
import { DEFAULT_MODEL } from "./models";
31+
import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models";
3232
import type { SettingsManager } from "./settings";
3333

3434
export interface ProcessSpawnedInfo {
@@ -487,6 +487,10 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
487487
options.model = DEFAULT_MODEL;
488488
}
489489

490+
if (!options.fallbackModel && options.model !== FALLBACK_MODEL) {
491+
options.fallbackModel = FALLBACK_MODEL;
492+
}
493+
490494
if (params.additionalDirectories) {
491495
options.additionalDirectories = params.additionalDirectories;
492496
}

packages/ui/src/features/sessions/components/buildConversationItems.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ function statusMsg(
123123
};
124124
}
125125

126+
function refusalStatusMsg(
127+
ts: number,
128+
status: "refusal" | "refusal_fallback",
129+
fields: { explanation?: string; fromModel?: string; toModel?: string } = {},
130+
): AcpMessage {
131+
return {
132+
type: "acp_message",
133+
ts,
134+
message: {
135+
jsonrpc: "2.0",
136+
method: "_posthog/status",
137+
params: { sessionId: "session-1", status, ...fields },
138+
},
139+
};
140+
}
141+
126142
describe("buildConversationItems", () => {
127143
it("extracts cloud prompt attachments into user messages", () => {
128144
const uri = makeAttachmentUri("/tmp/hello world.txt");
@@ -222,6 +238,56 @@ describe("buildConversationItems", () => {
222238
expect(result.isCompacting).toBe(false);
223239
});
224240

241+
it("renders a terminal refusal as a status row carrying the explanation", () => {
242+
const result = buildConversationItems(
243+
[
244+
userPromptMsg(1, 1, "hi"),
245+
refusalStatusMsg(2, "refusal", {
246+
explanation: "This request was declined.",
247+
}),
248+
],
249+
null,
250+
);
251+
252+
const statusItems = result.items.filter(
253+
(i): i is Extract<ConversationItem, { type: "session_update" }> =>
254+
i.type === "session_update" && i.update.sessionUpdate === "status",
255+
);
256+
expect(statusItems.map((i) => i.update)).toEqual([
257+
{
258+
sessionUpdate: "status",
259+
status: "refusal",
260+
explanation: "This request was declined.",
261+
},
262+
]);
263+
});
264+
265+
it("renders a refusal fallback status row carrying the model swap", () => {
266+
const result = buildConversationItems(
267+
[
268+
userPromptMsg(1, 1, "hi"),
269+
refusalStatusMsg(2, "refusal_fallback", {
270+
fromModel: "claude-fable-5",
271+
toModel: "claude-opus-4-8",
272+
}),
273+
],
274+
null,
275+
);
276+
277+
const statusItems = result.items.filter(
278+
(i): i is Extract<ConversationItem, { type: "session_update" }> =>
279+
i.type === "session_update" && i.update.sessionUpdate === "status",
280+
);
281+
expect(statusItems.map((i) => i.update)).toEqual([
282+
{
283+
sessionUpdate: "status",
284+
status: "refusal_fallback",
285+
fromModel: "claude-fable-5",
286+
toModel: "claude-opus-4-8",
287+
},
288+
]);
289+
});
290+
225291
it("marks cloud turns complete from structured turn completion notifications", () => {
226292
const result = buildConversationItems(
227293
[userPromptMsg(10, 42, "hello"), turnCompleteMsg(25)],

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,20 @@ function handleNotification(
535535
status: string;
536536
isComplete?: boolean;
537537
error?: string;
538+
explanation?: string;
539+
fromModel?: string;
540+
toModel?: string;
538541
};
542+
if (params.status === "refusal" || params.status === "refusal_fallback") {
543+
pushItem(b, {
544+
sessionUpdate: "status",
545+
status: params.status,
546+
explanation: params.explanation,
547+
fromModel: params.fromModel,
548+
toModel: params.toModel,
549+
});
550+
return;
551+
}
539552
if (params.status === "compacting") {
540553
if (params.isComplete) {
541554
// Successful compaction — flip the existing "Compacting…" status to

packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ export type RenderItem =
3535
isComplete?: boolean;
3636
/** Set when a status ends in failure (e.g. a failed compaction) so the row renders the error. */
3737
error?: string;
38+
/** Refusal statuses: display-only stop_details.explanation from the API. */
39+
explanation?: string;
40+
/** Refusal fallback: the model that declined the request. */
41+
fromModel?: string;
42+
/** Refusal fallback: the model that retried the request. */
43+
toModel?: string;
3844
}
3945
| {
4046
sessionUpdate: "error";
@@ -125,6 +131,9 @@ export const SessionUpdateView = memo(function SessionUpdateView({
125131
status={item.status}
126132
isComplete={item.isComplete}
127133
error={item.error}
134+
explanation={item.explanation}
135+
fromModel={item.fromModel}
136+
toModel={item.toModel}
128137
/>
129138
);
130139
case "error":

0 commit comments

Comments
 (0)