diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index 4e2c9e76de..dc0cc666d4 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -38,6 +38,12 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth - `SYSTEM_REMINDER` stripping from Read tool results - WebFetch `resourceLink` content enrichment - `customTitle` in listSessions (PostHog Code is ahead of upstream here) +- Refusal support: `Options.fallbackModel` defaults to `FALLBACK_MODEL` in + `session/options.ts`; `model_refusal_fallback` system messages emit a + `_posthog/status` notification (`refusal_fallback`) in `sdk-to-acp.ts`; a + terminal `stop_reason: "refusal"` emits `_posthog/status` (`refusal`) instead + of upstream's raw-explanation `agent_message_chunk` (supersedes the v0.42.0 + "Refusal handling" port and the v0.44.0 `model_refusal_fallback` skip) - SettingsManager `PreToolUse` hook for permission rules - `ensureLocalSettings` / `clearStatsigCache` - `ELECTRON_RUN_AS_NODE` / `ENABLE_TOOL_SEARCH` env vars diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7f2db920f0..173483077a 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -517,6 +517,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { let errored = false; let lastAssistantTotalUsage: number | null = null; let lastRefusalExplanation: string | null = null; + let lastRefusalCategory: string | null = null; let lastStreamUsage = { input_tokens: 0, output_tokens: 0, @@ -870,15 +871,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if ( (message as { stop_reason?: string }).stop_reason === "refusal" ) { - if (lastRefusalExplanation) { - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: lastRefusalExplanation }, - }, - }); - } + // The API's stop_details.explanation is integrator-facing prose, + // so surface the refusal as a structured status row rather than + // assistant text. + await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: params.sessionId, + status: "refusal", + ...(lastRefusalExplanation && { + explanation: lastRefusalExplanation, + }), + ...(lastRefusalCategory && { category: lastRefusalCategory }), + }); return { stopReason: "refusal", usage }; } @@ -1002,11 +1005,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (message.type === "assistant") { const inner = message.message as unknown as { stop_reason?: string | null; - stop_details?: { explanation?: string | null } | null; + stop_details?: { + category?: string | null; + explanation?: string | null; + } | null; }; if (inner.stop_reason === "refusal") { lastRefusalExplanation = inner.stop_details?.explanation ?? null; + lastRefusalCategory = inner.stop_details?.category ?? null; } } diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts index 1b30a17d1e..2ca3b563ba 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts @@ -4,6 +4,7 @@ import type { } from "@agentclientprotocol/sdk"; import type { SDKAssistantMessage, + SDKModelRefusalFallbackMessage, SDKPartialAssistantMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -12,6 +13,7 @@ import { Logger } from "../../../utils/logger"; import type { Session } from "../types"; import { handleStreamEvent, + handleSystemMessage, handleUserAssistantMessage, type MessageHandlerContext, stripMarkerTags, @@ -64,10 +66,14 @@ describe("stripMarkerTags", () => { function createHandlerContext() { const updates: SessionNotification[] = []; + const notifications: Array<{ method: string; params: unknown }> = []; const client = { sessionUpdate: async (notification: SessionNotification) => { updates.push(notification); }, + extNotification: async (method: string, params: unknown) => { + notifications.push({ method, params }); + }, } as unknown as AgentSideConnection; const context: MessageHandlerContext = { session: { @@ -86,7 +92,7 @@ function createHandlerContext() { thinkingIds: new Set(), }, }; - return { context, updates }; + return { context, updates, notifications }; } function streamEvent( @@ -361,3 +367,72 @@ describe("import replay (no client-side history)", () => { expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]); }); }); + +describe("handleSystemMessage model_refusal_fallback", () => { + function refusalFallbackMessage( + overrides: Partial = {}, + ): SDKModelRefusalFallbackMessage { + return { + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-fable-5", + fallback_model: "claude-opus-4-8", + request_id: "req_1", + api_refusal_category: "cyber", + api_refusal_explanation: "This request was declined.", + retracted_message_uuids: [], + content: "Retried on fallback model", + uuid: "00000000-0000-0000-0000-000000000009", + session_id: "test-session", + ...overrides, + }; + } + + it.each< + [string, Partial, Record] + >([ + [ + "emits a refusal_fallback status notification with the model swap", + {}, + { + sessionId: "test-session", + status: "refusal_fallback", + fromModel: "claude-fable-5", + toModel: "claude-opus-4-8", + explanation: "This request was declined.", + }, + ], + [ + "omits the explanation when the refused response carried none", + { api_refusal_explanation: null }, + { + sessionId: "test-session", + status: "refusal_fallback", + fromModel: "claude-fable-5", + toModel: "claude-opus-4-8", + }, + ], + ])("%s", async (_name, overrides, expectedParams) => { + const { context, updates, notifications } = createHandlerContext(); + + await handleSystemMessage(refusalFallbackMessage(overrides), context); + + expect(updates).toEqual([]); + expect(notifications).toEqual([ + { method: "_posthog/status", params: expectedParams }, + ]); + }); + + it.each(["revert", "sticky"] as const)( + "skips the notification for the legacy %s direction", + async (direction) => { + const { context, notifications } = createHandlerContext(); + + await handleSystemMessage(refusalFallbackMessage({ direction }), context); + + expect(notifications).toEqual([]); + }, + ); +}); diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index fe4197dacb..2cbe1d9c08 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -796,6 +796,29 @@ export async function handleSystemMessage( `Session ${sessionId}: failed to persist history: ${message.error}`, ); break; + case "model_refusal_fallback": { + logger.info("Refusal retried on fallback model", { + sessionId, + direction: message.direction, + originalModel: message.original_model, + fallbackModel: message.fallback_model, + category: message.api_refusal_category ?? undefined, + requestId: message.request_id ?? undefined, + }); + // Only "retry" is emitted live; "revert" and "sticky" are legacy enum + // values whose semantics don't match the "retried with" notice. + if (message.direction !== "retry") break; + await client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId, + status: "refusal_fallback", + fromModel: message.original_model, + toModel: message.fallback_model, + ...(message.api_refusal_explanation && { + explanation: message.api_refusal_explanation, + }), + }); + break; + } case "permission_denied": { const reason = message.decision_reason ?? message.message; await client.sessionUpdate({ diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 5fad1e729b..4ea33fd269 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -2,6 +2,10 @@ import type { EffortLevel } from "../types"; export const DEFAULT_MODEL = "opus"; +// Refusal/overload rescue target. The SDK rejects fallbackModel === Options.model +// at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL. +export const FALLBACK_MODEL = "claude-opus-4-8"; + // Default thinking level when the user hasn't picked one. Adaptive-only models // like claude-fable-5 reject the SDK's no-effort `thinking: { type: "disabled" }` // shape, so effort-capable models default to high to keep thinking enabled. diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index f214caa8cb..87fd3b2ae0 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -92,6 +92,29 @@ describe("buildSessionOptions", () => { expect(options.agents?.["ph-explore"]).toEqual(override); }); + it.each([ + ["a new session", () => makeParams()], + ["a resumed session", () => ({ ...makeParams(), isResume: true })], + ])( + "defaults fallbackModel on %s so refusals and overloads retry on another model", + (_label, params) => { + const options = buildSessionOptions(params()); + + expect(options.fallbackModel).toBe("claude-opus-4-8"); + // The SDK throws at spawn when fallbackModel equals Options.model. + expect(options.fallbackModel).not.toBe(options.model); + }, + ); + + it("preserves a caller-provided fallbackModel", () => { + const options = buildSessionOptions({ + ...makeParams(), + userProvidedOptions: { fallbackModel: "claude-sonnet-5" }, + }); + + expect(options.fallbackModel).toBe("claude-sonnet-5"); + }); + it("threads onEnsureLocalToolsConnected into the signed-commit guard (cloud)", async () => { const healSpy = vi.fn().mockResolvedValue(true); await runPreToolUseHooks( diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 1e3a520a30..fa71653b2d 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -28,7 +28,7 @@ import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; import { APPENDED_INSTRUCTIONS } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; -import { DEFAULT_MODEL } from "./models"; +import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models"; import type { SettingsManager } from "./settings"; export interface ProcessSpawnedInfo { @@ -487,6 +487,10 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { options.model = DEFAULT_MODEL; } + if (!options.fallbackModel && options.model !== FALLBACK_MODEL) { + options.fallbackModel = FALLBACK_MODEL; + } + if (params.additionalDirectories) { options.additionalDirectories = params.additionalDirectories; } diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 83194fa8c9..9a4faaabe8 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -123,6 +123,22 @@ function statusMsg( }; } +function refusalStatusMsg( + ts: number, + status: "refusal" | "refusal_fallback", + fields: { explanation?: string; fromModel?: string; toModel?: string } = {}, +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/status", + params: { sessionId: "session-1", status, ...fields }, + }, + }; +} + describe("buildConversationItems", () => { it("extracts cloud prompt attachments into user messages", () => { const uri = makeAttachmentUri("/tmp/hello world.txt"); @@ -222,6 +238,56 @@ describe("buildConversationItems", () => { expect(result.isCompacting).toBe(false); }); + it("renders a terminal refusal as a status row carrying the explanation", () => { + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "hi"), + refusalStatusMsg(2, "refusal", { + explanation: "This request was declined.", + }), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + expect(statusItems.map((i) => i.update)).toEqual([ + { + sessionUpdate: "status", + status: "refusal", + explanation: "This request was declined.", + }, + ]); + }); + + it("renders a refusal fallback status row carrying the model swap", () => { + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "hi"), + refusalStatusMsg(2, "refusal_fallback", { + fromModel: "claude-fable-5", + toModel: "claude-opus-4-8", + }), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + expect(statusItems.map((i) => i.update)).toEqual([ + { + sessionUpdate: "status", + status: "refusal_fallback", + fromModel: "claude-fable-5", + toModel: "claude-opus-4-8", + }, + ]); + }); + it("marks cloud turns complete from structured turn completion notifications", () => { const result = buildConversationItems( [userPromptMsg(10, 42, "hello"), turnCompleteMsg(25)], diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index bb7b55100f..37174a9f87 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -535,7 +535,20 @@ function handleNotification( status: string; isComplete?: boolean; error?: string; + explanation?: string; + fromModel?: string; + toModel?: string; }; + if (params.status === "refusal" || params.status === "refusal_fallback") { + pushItem(b, { + sessionUpdate: "status", + status: params.status, + explanation: params.explanation, + fromModel: params.fromModel, + toModel: params.toModel, + }); + return; + } if (params.status === "compacting") { if (params.isComplete) { // Successful compaction — flip the existing "Compacting…" status to diff --git a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index aa87f4bfd6..1de866d66b 100644 --- a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -35,6 +35,12 @@ export type RenderItem = isComplete?: boolean; /** Set when a status ends in failure (e.g. a failed compaction) so the row renders the error. */ error?: string; + /** Refusal statuses: display-only stop_details.explanation from the API. */ + explanation?: string; + /** Refusal fallback: the model that declined the request. */ + fromModel?: string; + /** Refusal fallback: the model that retried the request. */ + toModel?: string; } | { sessionUpdate: "error"; @@ -125,6 +131,9 @@ export const SessionUpdateView = memo(function SessionUpdateView({ status={item.status} isComplete={item.isComplete} error={item.error} + explanation={item.explanation} + fromModel={item.fromModel} + toModel={item.toModel} /> ); case "error": diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx index d4ff94afde..b83b122e1a 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx @@ -1,6 +1,11 @@ -import { Spinner, XCircle } from "@phosphor-icons/react"; +import { + ArrowsClockwise, + ShieldWarning, + Spinner, + XCircle, +} from "@phosphor-icons/react"; import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; -import { Box, Flex, Text } from "@radix-ui/themes"; +import { Box, Callout, Flex, Text } from "@radix-ui/themes"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; interface StatusNotificationViewProps { @@ -8,17 +13,75 @@ interface StatusNotificationViewProps { isComplete?: boolean; /** Failure reason, set on a `compacting_failed` status. */ error?: string; + /** Refusal statuses: display-only stop_details.explanation from the API. */ + explanation?: string; + /** Refusal fallback: the model that declined the request. */ + fromModel?: string; + /** Refusal fallback: the model that retried the request. */ + toModel?: string; } export function StatusNotificationView({ status, isComplete, error, + explanation, + fromModel, + toModel, }: StatusNotificationViewProps) { // New thread renders status notes as centered separator markers; the legacy thread keeps its // bordered rows so ConversationView is unchanged when the chat thread is off. const chatChrome = useChatThreadChrome(); + // Terminal refusal: the safety classifier declined the request and no + // fallback model rescued it. Rendered as a callout in both chromes. + if (status === "refusal") { + return ( + + + + + + + + + Claude declined to continue with this request. + + {explanation && ( + {explanation} + )} + + Try rephrasing your request, or switch models and retry. + + + + + + ); + } + + if (status === "refusal_fallback") { + const message = + fromModel && toModel + ? `${fromModel} declined this request, retried with ${toModel}` + : "Request declined, retried with the fallback model"; + if (chatChrome) { + return ( + + {message} + + ); + } + return ( + + + + {message} + + + ); + } + // A failed compaction (e.g. "Not enough messages to compact"). The matching `compacting` spinner // is cleared separately; this row reports the outcome. if (status === "compacting_failed") {