Skip to content
Closed
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
262 changes: 260 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2234,6 +2234,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
readonly apiErrorStatus?: number;
// null omits the field, as the CLI does on a zero-turn result.
readonly terminalReason?: SDKResultMessage["terminal_reason"] | null;
readonly permissionDenials?: SDKResultMessage["permission_denials"];
readonly usage?: {
readonly input_tokens: number;
readonly output_tokens: number;
Expand All @@ -2258,7 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
cache_read_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
permission_denials: input.permissionDenials ?? [],
uuid: input.uuid,
session_id: WAKE_NATIVE_SESSION,
...(input.origin === undefined ? {} : { origin: input.origin }),
Expand Down Expand Up @@ -2372,6 +2373,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
yield* Queue.unbounded<
Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
>();
const runtimeRequestReceipts =
yield* Queue.unbounded<
Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "runtime_request.updated" }>
>();
let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
Expand Down Expand Up @@ -2473,6 +2478,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") {
yield* Queue.offer(systemNoticeReceipts, event);
}
if (event.type === "runtime_request.updated") {
yield* Queue.offer(runtimeRequestReceipts, event);
}
}),
),
Effect.forkScoped,
Expand Down Expand Up @@ -2509,6 +2517,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
terminalReceipts,
subagentReceipts,
systemNoticeReceipts,
runtimeRequestReceipts,
getOpenedOptions: () => openedOptions,
terminalEvents,
hasPendingBackgroundWork,
Expand Down Expand Up @@ -4258,6 +4267,234 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

describe("permission denials Claude Code made on its own", () => {
const AUTO_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "auto",
interactionMode: "default",
cwd: "/workspace",
});
const APPROVAL_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "approval-required",
interactionMode: "default",
cwd: "/workspace",
});
const CLASSIFIER_REASON =
"Permission for this action has been denied. Reason: cannot assess the action due to earlier conversation content.";

const makeDenialHarness = Effect.gen(function* () {
const harness = yield* makeWakeHarness;
let sequence = 900;
let turn = 0;
const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
const startTurn = Effect.fnUntraced(function* (
runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
) {
turn++;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make(`attempt-claude-denials-${turn}`),
providerTurnOrdinal: turn,
text: "Run the checks.",
attachments: [],
runtimePolicy,
}),
);
});
// A tool call Claude made and the failed tool_result it got back.
const failedToolCall = Effect.fnUntraced(function* (input: {
readonly toolUseId: string;
readonly toolName: string;
readonly error: string;
}) {
yield* harness.offerAndWait(
claudeSdkFrame({
type: "assistant",
message: {
id: `msg_${nextUuid()}`,
model: "claude-sonnet-4-6",
type: "message",
role: "assistant",
content: [{ type: "tool_use", id: input.toolUseId, name: input.toolName, input: {} }],
stop_reason: "tool_use",
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
parent_tool_use_id: null,
uuid: nextUuid(),
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* harness.offerAndWait(
claudeSdkFrame({
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: input.toolUseId,
content: input.error,
is_error: true,
},
],
},
parent_tool_use_id: null,
uuid: nextUuid(),
session_id: WAKE_NATIVE_SESSION,
}),
);
});
const finishTurn = Effect.fnUntraced(function* (
denials: SDKResultMessage["permission_denials"],
) {
yield* harness.offerAndWait(
makeResultFrame({
uuid: nextUuid(),
result: "I could not run the checks.",
permissionDenials: denials,
}),
);
return yield* Queue.take(harness.terminalReceipts);
});
const notices = () =>
harness.events.flatMap((event) =>
event.type === "turn_item.updated" && event.turnItem.type === "system_notice"
? [event.turnItem]
: [],
);
const canUseTool = (
toolName: string,
toolInput: Record<string, unknown>,
toolUseID: string,
signal = new AbortController().signal,
) =>
Effect.promise(() =>
harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, {
signal,
toolUseID,
requestId: `request-${toolUseID}`,
}),
);
return { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool };
});

it.effect(
"flags classifier denials with their reason, and only on the turn that had them",
() =>
Effect.gen(function* () {
const { startTurn, failedToolCall, finishTurn, notices } = yield* makeDenialHarness;
yield* startTurn(AUTO_RUNTIME_POLICY);
for (const toolUseId of ["toolu-classifier-1", "toolu-classifier-2"]) {
yield* failedToolCall({ toolUseId, toolName: "Bash", error: CLASSIFIER_REASON });
}
const terminal = yield* finishTurn([
{ tool_name: "Bash", tool_use_id: "toolu-classifier-1", tool_input: { command: "ls" } },
{
tool_name: "Bash",
tool_use_id: "toolu-classifier-2",
tool_input: { command: "pwd" },
},
]);

assert.equal(terminal.status, "completed");
const [notice, ...rest] = notices();
assert.lengthOf(rest, 0);
assert.equal(notice?.type, "system_notice");
if (notice?.type !== "system_notice") return;
assert.equal(notice.message, `Auto mode denied 2 actions (Bash): ${CLASSIFIER_REASON}`);
assert.isTrue(notice.needsAttention);
assert.equal(notice.providerTurnId, terminal.providerTurnId);

// The next turn has no denials, so it adds nothing.
yield* startTurn(AUTO_RUNTIME_POLICY);
assert.equal((yield* finishTurn([])).status, "completed");
assert.lengthOf(notices(), 1);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("leaves out the calls T3 denied itself", () =>
Effect.gen(function* () {
const { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool } =
yield* makeDenialHarness;
yield* startTurn(APPROVAL_RUNTIME_POLICY);

// ExitPlanMode: T3 captures the plan and denies the tool.
assert.equal(
(yield* canUseTool("ExitPlanMode", { plan: "# Plan" }, "toolu-plan"))?.behavior,
"deny",
);
// A question the CLI cancelled before the user answered.
const cancelled = new AbortController();
cancelled.abort();
assert.equal(
(yield* canUseTool(
"AskUserQuestion",
{ questions: [] },
"toolu-question",
cancelled.signal,
))?.behavior,
"deny",
);
// An approval the user declined.
const decline = yield* canUseTool("Write", { file_path: "/tmp/x" }, "toolu-write").pipe(
Effect.forkScoped,
);
// The cancelled question raised a request first; skip to the Write one.
let approval = yield* Queue.take(harness.runtimeRequestReceipts);
while (approval.runtimeRequest.nativeRequestRef?.nativeId !== "toolu-write") {
approval = yield* Queue.take(harness.runtimeRequestReceipts);
}
yield* harness.runtime.respondToRuntimeRequest({
requestId: approval.runtimeRequest.id,
decision: "decline",
});
assert.equal((yield* Fiber.join(decline))?.behavior, "deny");
yield* failedToolCall({
toolUseId: "toolu-write",
toolName: "Write",
error: "User declined tool execution.",
});

// A deny rule Claude Code applied on its own, in the same turn.
yield* failedToolCall({
toolUseId: "toolu-rule",
toolName: "Bash",
error: "Denied by rule.",
});

const terminal = yield* finishTurn([
{ tool_name: "ExitPlanMode", tool_use_id: "toolu-plan", tool_input: {} },
{ tool_name: "AskUserQuestion", tool_use_id: "toolu-question", tool_input: {} },
{ tool_name: "Write", tool_use_id: "toolu-write", tool_input: {} },
{ tool_name: "Bash", tool_use_id: "toolu-rule", tool_input: {} },
]);
assert.equal(terminal.status, "completed");
assert.deepEqual(
notices().map((notice) => (notice.type === "system_notice" ? notice.message : null)),
["Claude denied 1 action (Bash): Denied by rule."],
);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);
});

it.effect("removes deleted tasks without resurrecting them on later updates or turns", () =>
Effect.gen(function* () {
const harness = yield* makeTaskToolHarness;
Expand Down Expand Up @@ -5552,7 +5789,24 @@ describe("ClaudeAdapterV2 background wake turns", () => {
"The client captured your proposed plan",
);
yield* Queue.offer(harness.sdkMessages, planToolResult);
yield* Queue.offer(harness.sdkMessages, wakeResult);
// The wake result lists the captured plan among its denials. T3 made
// that denial while the frame was held, so the turn it lands in
// must still recognize it.
yield* Queue.offer(
harness.sdkMessages,
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000745",
result: WAKE_RESULT_TEXT,
origin: { kind: "task-notification" },
permissionDenials: [
{
tool_name: "ExitPlanMode",
tool_use_id: planToolUseId,
tool_input: { plan: planMarkdown },
},
],
}),
);
yield* awaitUntil(
() => harness.continuationRequests.length === 1,
"continuation request",
Expand Down Expand Up @@ -5641,6 +5895,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
userRunItems.map((item) => item.type),
["assistant_message"],
);
assert.notInclude(
[...latestItems.values()].map((item) => item.type),
"system_notice",
);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
Expand Down
Loading
Loading