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
92 changes: 75 additions & 17 deletions apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import {
Expand Down Expand Up @@ -59,6 +60,11 @@ import {
WORKSPACE_NEVER_POLICY,
WEB_SEARCH_PROMPT,
} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
import {
DENIED_WRITE_POLICY,
TOOL_CALL_DENIED_WRITE_PROMPT,
TOOL_CALL_DENIED_WRITE_TARGET,
} from "../src/orchestration-v2/testkit/fixtures/tool_call_denied_write/input.ts";
import {
validateClaudeReplayRecordingSelection,
type ClaudeRecordingQueryMode,
Expand Down Expand Up @@ -136,6 +142,15 @@ const CLAUDE_RECORDINGS = {
enableTools: true,
runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
},
tool_call_denied_write: {
prompts: [TOOL_CALL_DENIED_WRITE_PROMPT],
defaultTranscriptFile: "fixtures/tool_call_denied_write/claude_transcript.ndjson",
queryMode: "streaming",
enableTools: true,
runtimePolicyOverride: DENIED_WRITE_POLICY,
permissionDecision: "decline",
expectedAbsentWorkspacePaths: [TOOL_CALL_DENIED_WRITE_TARGET],
},
tool_call_restricted_granular: {
prompts: [TOOL_CALL_WRITE_PROMPT],
defaultTranscriptFile: "fixtures/tool_call_restricted_granular/claude_transcript.ndjson",
Expand Down Expand Up @@ -264,18 +279,24 @@ const recording = CLAUDE_RECORDINGS[scenario as keyof typeof CLAUDE_RECORDINGS];
const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

if (recording === undefined) {
throw new Error(
`Claude replay fixture '${scenario}' is not configured. ` +
"TODO: approval fixtures need permission callback recording before they can be generated.",
);
throw new Error(`Claude replay fixture '${scenario}' is not configured.`);
}

const positionalOutputPath = process.argv[2]?.startsWith("--") ? undefined : process.argv[2];
const path = await Effect.runPromise(
Effect.service(Path.Path).pipe(Effect.provide(NodeServices.layer)),
);
const outputPath =
readArgValue("--out") ??
positionalOutputPath ??
new URL(`../src/orchestration-v2/testkit/${recording.defaultTranscriptFile}`, import.meta.url)
.pathname;
(await Effect.runPromise(
path.fromFileUrl(
new URL(
`../src/orchestration-v2/testkit/${recording.defaultTranscriptFile}`,
import.meta.url,
),
),
));

function encodeTranscriptNdjson(
transcript: Awaited<ReturnType<typeof recordClaudeAgentSdkReplayTranscript>>,
Expand All @@ -288,15 +309,6 @@ function encodeTranscriptNdjson(
].join("\n");
}

function dirname(filePath: string): string {
const normalized = filePath.replace(/\/+$/u, "");
const lastSlash = normalized.lastIndexOf("/");
if (lastSlash < 0) {
return ".";
}
return lastSlash === 0 ? "/" : normalized.slice(0, lastSlash);
}

function joinPath(directory: string, fileName: string): string {
return `${directory.replace(/\/+$/u, "")}/${fileName.replace(/^\/+/u, "")}`;
}
Expand Down Expand Up @@ -365,6 +377,37 @@ const cwd =
: await makeCheckpointWorkspace(`claude-agent-sdk-record-${scenario}`));
const shouldRemoveCwd = process.env.T3_CLAUDE_REPLAY_CWD === undefined;

const expectedAbsentWorkspacePaths =
"expectedAbsentWorkspacePaths" in recording ? recording.expectedAbsentWorkspacePaths : [];

// Scenarios that deny a tool call prove the denial by checking the real
// recording workspace, not just the transcript: the target must be absent
// before and after the turn, and the verified paths are preserved in the
// transcript metadata before the temporary workspace is removed.
async function assertWorkspacePathsAbsent(phase: "before" | "after"): Promise<void> {
const presentPaths = await runFileSystem(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const present: Array<string> = [];
for (const relativePath of expectedAbsentWorkspacePaths) {
if (yield* fs.exists(joinPath(cwd, relativePath))) {
present.push(relativePath);
}
}
return present;
}),
);
if (presentPaths.length > 0) {
throw new Error(
`Claude replay fixture '${scenario}' expected ${presentPaths
.map((relativePath) => `'${relativePath}'`)
.join(
", ",
)} to be absent ${phase} the recorded turn, but found in the recording workspace.`,
);
}
}

if (shouldRemoveCwd && (scenario === "tool_call_read_only" || scenario === "subagent")) {
await runFileSystem(
Effect.gen(function* () {
Expand Down Expand Up @@ -398,6 +441,7 @@ try {
});
const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(runtimePolicy);

await assertWorkspacePathsAbsent("before");
const transcript = await recordClaudeAgentSdkReplayTranscript({
scenario,
prompts,
Expand All @@ -418,13 +462,27 @@ try {
? {}
: { allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions }),
...(queryPolicy.installPermissionCallback ? { enablePermissionCallback: true } : {}),
...("permissionDecision" in recording
? { permissionDecision: recording.permissionDecision }
: {}),
...("interruptAfter" in recording ? { interruptAfter: recording.interruptAfter } : {}),
});
await assertWorkspacePathsAbsent("after");
const transcriptWithEvidence =
expectedAbsentWorkspacePaths.length === 0
? transcript
: {
...transcript,
metadata: {
...transcript.metadata,
verifiedAbsentWorkspacePaths: [...expectedAbsentWorkspacePaths],
},
};
await runFileSystem(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.makeDirectory(dirname(outputPath), { recursive: true });
yield* fs.writeFileString(outputPath, encodeTranscriptNdjson(transcript));
yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
yield* fs.writeFileString(outputPath, encodeTranscriptNdjson(transcriptWithEvidence));
}),
);
await Effect.runPromise(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import {
ProviderInstanceId,
ProviderSessionId,
Expand All @@ -13,24 +17,31 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { vi } from "vite-plus/test";

import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts";
import {
CLAUDE_PROVIDER,
ClaudeAgentSdkQueryRunnerError,
makeClaudeUserMessage,
permissionResultFromDecision,
type ClaudeAgentSdkQueryOptions,
} from "./ClaudeAdapterV2.ts";
import {
CLAUDE_AGENT_SDK_REPLAY_PROTOCOL,
ClaudeOrchestratorReplayHarness,
ClaudeReplayFrameMismatchError,
ClaudeReplayIncompleteError,
ClaudeReplayRuntimeExitError,
ClaudeReplayUnexpectedOutboundError,
makeReplayQueryRunner,
recordInterruptedClaudeQuery,
recordMessagesUntilTurnResultAndFinalize,
resolveClaudeRecordingExecutablePath,
} from "./ClaudeAdapterV2.testkit.ts";
import { makeProviderReplayGate } from "../testkit/ProviderReplayGate.testkit.ts";
import { readProviderReplayTranscript } from "../testkit/ReplayTranscriptNdjson.ts";

const isClaudeAgentSdkQueryRunnerError = Schema.is(ClaudeAgentSdkQueryRunnerError);
const isClaudeReplayFrameMismatchError = Schema.is(ClaudeReplayFrameMismatchError);
const isClaudeReplayIncompleteError = Schema.is(ClaudeReplayIncompleteError);
const isClaudeReplayRuntimeExitError = Schema.is(ClaudeReplayRuntimeExitError);
const isClaudeReplayUnexpectedOutboundError = Schema.is(ClaudeReplayUnexpectedOutboundError);
Expand Down Expand Up @@ -767,4 +778,143 @@ describe("ClaudeAdapterV2 replay testkit", () => {
assert.equal(terminalEntry.label, "result");
}
});

it.effect("rejects the denied-write transcript when an allow response is substituted", () =>
Effect.gen(function* () {
const rawTranscript = yield* readProviderReplayTranscript(
new URL(
"../testkit/fixtures/tool_call_denied_write/claude_transcript.ndjson",
import.meta.url,
),
).pipe(Effect.provide(NodeServices.layer));
const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript);

const expectedOutboundFrame = (label: string): unknown => {
const entry = transcript.entries.find(
(candidate): candidate is Extract<ProviderReplayEntry, { type: "expect_outbound" }> =>
candidate.type === "expect_outbound" && candidate.label === label,
);
if (entry === undefined) {
throw new Error(`denied-write transcript is missing outbound frame ${label}.`);
}
return entry.frame;
};
const openFrame = expectedOutboundFrame("query.open") as {
readonly options: ClaudeAgentSdkQueryOptions;
};
const offerFrame = expectedOutboundFrame("prompt.offer:1") as {
readonly message: SDKUserMessage;
};

const canUseTool: NonNullable<ClaudeAgentSdkQueryOptions["canUseTool"]> = (
toolName,
toolInput,
options,
) =>
Promise.resolve(
permissionResultFromDecision({
toolName,
decision: "accept",
toolInput,
toolUseID: options.toolUseID,
...(options.suggestions === undefined ? {} : { suggestions: options.suggestions }),
}),
);

const runner = makeReplayQueryRunner(transcript);
const session = runner.open({
options: { ...openFrame.options, canUseTool },
threadId: ThreadId.make("thread-denied-write-allow-substitution"),
providerSessionId: ProviderSessionId.make(
"provider-session-denied-write-allow-substitution",
),
});
yield* session.offer(offerFrame.message);

const exit = yield* Effect.exit(Stream.runDrain(session.messages));

assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause);
assert.isTrue(isClaudeAgentSdkQueryRunnerError(error));
if (isClaudeAgentSdkQueryRunnerError(error)) {
assert.isTrue(isClaudeReplayFrameMismatchError(error.cause));
if (isClaudeReplayFrameMismatchError(error.cause)) {
assert.equal(error.cause.label, "permission.response:Write");
}
}
}
}),
);
});

describe("resolveClaudeRecordingExecutablePath", () => {
const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm";
const NPM_SHIM = `${NPM_DIR}\\claude.cmd`;
const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;

function withRecordingResolution(input: {
readonly platform: "win32" | "darwin";
readonly resolvedCommand: string | undefined;
readonly existingFiles?: ReadonlyArray<string>;
}) {
const existing = new Set(input.existingFiles ?? []);
return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provideService(HostProcessPlatform, input.platform),
Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand),
Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)),
);
}

it.effect("returns the resolved path on non-Windows platforms", () =>
Effect.gen(function* () {
assert.equal(
yield* resolveClaudeRecordingExecutablePath({}).pipe(
withRecordingResolution({
platform: "darwin",
resolvedCommand: "/usr/local/bin/claude",
}),
),
"/usr/local/bin/claude",
);
}),
);

it.effect("leaves SDK discovery in place when nothing resolves on PATH", () =>
Effect.gen(function* () {
assert.isUndefined(
yield* resolveClaudeRecordingExecutablePath({}).pipe(
withRecordingResolution({ platform: "darwin", resolvedCommand: undefined }),
),
);
}),
);

it.effect("follows a Windows launcher shim to its package entry", () =>
Effect.gen(function* () {
assert.equal(
yield* resolveClaudeRecordingExecutablePath({}).pipe(
withRecordingResolution({
platform: "win32",
resolvedCommand: NPM_SHIM,
existingFiles: [NPM_PACKAGE_EXE],
}),
),
NPM_PACKAGE_EXE,
);
}),
);

it.effect(
"leaves SDK discovery in place for a Windows launcher shim without a package entry",
() =>
Effect.gen(function* () {
assert.isUndefined(
yield* resolveClaudeRecordingExecutablePath({}).pipe(
withRecordingResolution({ platform: "win32", resolvedCommand: NPM_SHIM }),
),
);
}),
);
});
Loading
Loading