Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 71 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,67 @@ 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("emits a refusal_fallback status notification with the model swap", async () => {
const { context, updates, notifications } = createHandlerContext();

await handleSystemMessage(refusalFallbackMessage(), context);

expect(updates).toEqual([]);
expect(notifications).toEqual([
{
method: "_posthog/status",
params: {
sessionId: "test-session",
status: "refusal_fallback",
fromModel: "claude-fable-5",
toModel: "claude-opus-4-8",
explanation: "This request was declined.",
},
},
]);
});

it("omits the explanation when the refused response carried none", async () => {
const { context, notifications } = createHandlerContext();

await handleSystemMessage(
refusalFallbackMessage({ api_refusal_explanation: null }),
context,
);

expect(notifications).toEqual([
{
method: "_posthog/status",
params: {
sessionId: "test-session",
status: "refusal_fallback",
fromModel: "claude-fable-5",
toModel: "claude-opus-4-8",
},
},
]);
});
Comment thread
charlesvien marked this conversation as resolved.
Outdated
});
20 changes: 20 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,26 @@ 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,
});
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("defaults fallbackModel so refusals and overloads retry on another model", () => {
const options = buildSessionOptions(makeParams());

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("defaults fallbackModel on resumed sessions", () => {
const options = buildSessionOptions({ ...makeParams(), isResume: true });

expect(options.fallbackModel).toBe("claude-opus-4-8");
});
Comment thread
charlesvien marked this conversation as resolved.
Outdated

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