Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/agent/src/adapters/claude/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 17 additions & 10 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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;
}
}

Expand Down
77 changes: 76 additions & 1 deletion packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from "@agentclientprotocol/sdk";
import type {
SDKAssistantMessage,
SDKModelRefusalFallbackMessage,
SDKPartialAssistantMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
Expand All @@ -12,6 +13,7 @@ import { Logger } from "../../../utils/logger";
import type { Session } from "../types";
import {
handleStreamEvent,
handleSystemMessage,
handleUserAssistantMessage,
type MessageHandlerContext,
stripMarkerTags,
Expand Down Expand Up @@ -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: {
Expand All @@ -86,7 +92,7 @@ function createHandlerContext() {
thinkingIds: new Set(),
},
};
return { context, updates };
return { context, updates, notifications };
}

function streamEvent(
Expand Down Expand Up @@ -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> = {},
): 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<SDKModelRefusalFallbackMessage>, Record<string, unknown>]
>([
[
"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([]);
},
);
});
23 changes: 23 additions & 0 deletions packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
charlesvien marked this conversation as resolved.
case "permission_denied": {
const reason = message.decision_reason ?? message.message;
await client.sessionUpdate({
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/adapters/claude/session/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions packages/agent/src/adapters/claude/session/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<ConversationItem, { type: "session_update" }> =>
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<ConversationItem, { type: "session_update" }> =>
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)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading