1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Console from "effect/Console";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7
8import {
9 recordClaudeAgentSdkReplayTranscript,
10 CLAUDE_AGENT_SDK_REPLAY_PROTOCOL,
11} from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts";
12import { claudeRuntimeQueryPolicyForRuntimePolicy } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts";
13import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
14import type { RuntimePolicyV2Override } from "../src/orchestration-v2/RuntimePolicy.ts";
15import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
16import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
17import {
18 MESSAGE_STEERING_INITIAL_PROMPT,
19 MULTI_TURN_FIRST_PROMPT,
20 MESSAGE_STEERING_STEER_PROMPT,
21 READ_ONLY_NEVER_POLICY,
22 READ_ONLY_ON_REQUEST_POLICY,
23 RESTRICTED_GRANULAR_POLICY,
24 MULTI_TURN_SECOND_PROMPT,
25 SIMPLE_PROMPT,
26 SUBAGENT_PROMPT,
27 THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
28 THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
29 THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
30 THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
31 THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
32 THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
33 THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
34 THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
35 THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
36 THREAD_FORK_NATIVE_SOURCE_PROMPT,
37 THREAD_FORK_NATIVE_TARGET_PROMPT,
38 THREAD_MERGE_BACK_FORK_PROMPT,
39 THREAD_MERGE_BACK_HANDOFF_PROMPT,
40 THREAD_MERGE_BACK_RECALL_PROMPT,
41 THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
42 THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
43 THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
44 THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
45 THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
46 THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
47 THREAD_MERGE_BACK_SOURCE_PROMPT,
48 THREAD_ROLLBACK_AFTER_PROMPT,
49 THREAD_ROLLBACK_FIRST_PROMPT,
50 THREAD_ROLLBACK_SECOND_PROMPT,
51 TOOL_CALL_READ_ONLY_PROMPT,
52 TOOL_CALL_READ_ONLY_WORKSPACE_ROOT,
53 TOOL_CALL_WRITE_PROMPT,
54 TURN_INTERRUPT_MID_TOOL_PROMPT,
55 TURN_INTERRUPT_PROMPT,
56 TURN_INTERRUPT_RECOVERY_PROMPT,
57 WORKSPACE_NEVER_POLICY,
58 WEB_SEARCH_PROMPT,
59} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
60import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts";
61import {
62 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
63 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_LAUNCH_PROMPT,
64 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
65 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
66} from "../src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts";
67import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/input.ts";
68import { CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/input.ts";
69import { CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/input.ts";
70import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts";
71import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/input.ts";
72import { CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts";
73import {
74 CLAUDE_BACKGROUND_TASK_WAKE_FOLLOW_UP_PROMPT,
75 CLAUDE_BACKGROUND_TASK_WAKE_PROMPT,
76} from "../src/orchestration-v2/testkit/fixtures/claude_background_task_wake/input.ts";
77import {
78 DENIED_WRITE_POLICY,
79 TOOL_CALL_DENIED_WRITE_PROMPT,
80 TOOL_CALL_DENIED_WRITE_TARGET,
81} from "../src/orchestration-v2/testkit/fixtures/tool_call_denied_write/input.ts";
82import {
83 validateClaudeReplayRecordingSelection,
84 type ClaudeRecordingQueryMode,
85} from "./claudeReplayRecordingConfig.ts";
86
87const CLAUDE_RECORDINGS = {
88 simple: {
89 prompts: [SIMPLE_PROMPT],
90 defaultTranscriptFile: "fixtures/simple/claude_transcript.ndjson",
91 queryMode: "streaming",
92 enableTools: true,
93 },
94 multi_turn: {
95 prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
96 defaultTranscriptFile: "fixtures/multi_turn/claude_transcript.ndjson",
97 queryMode: "streaming",
98 enableTools: true,
99 },
100 multi_turn_restart: {
101 prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
102 defaultTranscriptFile: "fixtures/multi_turn_restart/claude_transcript.ndjson",
103 queryMode: "restart",
104 enableTools: true,
105 },
106 queued_turn: {
107 prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
108 defaultTranscriptFile: "fixtures/queued_turn/claude_transcript.ndjson",
109 queryMode: "streaming",
110 enableTools: true,
111 },
112 message_steering: {
113 prompts: [MESSAGE_STEERING_INITIAL_PROMPT, MESSAGE_STEERING_STEER_PROMPT],
114 defaultTranscriptFile: "fixtures/message_steering/claude_transcript.ndjson",
115 queryMode: "active_steering",
116 enableTools: true,
117 },
118 turn_interrupt_mid_tool: {
119 prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT],
120 defaultTranscriptFile: "fixtures/turn_interrupt_mid_tool/claude_transcript.ndjson",
121 queryMode: "interrupt",
122 enableTools: true,
123 interruptAfter: "tool_use",
124 },
125 turn_interrupt: {
126 prompts: [TURN_INTERRUPT_PROMPT],
127 defaultTranscriptFile: "fixtures/turn_interrupt/claude_transcript.ndjson",
128 queryMode: "interrupt",
129 enableTools: true,
130 },
131 turn_interrupt_restart: {
132 prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT, TURN_INTERRUPT_RECOVERY_PROMPT],
133 defaultTranscriptFile: "fixtures/turn_interrupt_restart/claude_transcript.ndjson",
134 queryMode: "interrupt_restart",
135 enableTools: true,
136 interruptAfter: "tool_use",
137 },
138 tool_call_read_only: {
139 prompts: [TOOL_CALL_READ_ONLY_PROMPT],
140 defaultTranscriptFile: "fixtures/tool_call_read_only/claude_transcript.ndjson",
141 queryMode: "streaming",
142 enableTools: true,
143 runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
144 },
145 tool_call_read_only_on_request: {
146 prompts: [TOOL_CALL_WRITE_PROMPT],
147 defaultTranscriptFile: "fixtures/tool_call_read_only_on_request/claude_transcript.ndjson",
148 queryMode: "streaming",
149 enableTools: true,
150 runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
151 },
152 tool_call_workspace_never: {
153 prompts: [TOOL_CALL_WRITE_PROMPT],
154 defaultTranscriptFile: "fixtures/tool_call_workspace_never/claude_transcript.ndjson",
155 queryMode: "streaming",
156 enableTools: true,
157 runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
158 },
159 tool_call_denied_write: {
160 prompts: [TOOL_CALL_DENIED_WRITE_PROMPT],
161 defaultTranscriptFile: "fixtures/tool_call_denied_write/claude_transcript.ndjson",
162 queryMode: "streaming",
163 enableTools: true,
164 runtimePolicyOverride: DENIED_WRITE_POLICY,
165 permissionDecision: "decline",
166 expectedAbsentWorkspacePaths: [TOOL_CALL_DENIED_WRITE_TARGET],
167 },
168 tool_call_restricted_granular: {
169 prompts: [TOOL_CALL_WRITE_PROMPT],
170 defaultTranscriptFile: "fixtures/tool_call_restricted_granular/claude_transcript.ndjson",
171 queryMode: "streaming",
172 enableTools: true,
173 runtimePolicyOverride: RESTRICTED_GRANULAR_POLICY,
174 },
175 web_search: {
176 prompts: [WEB_SEARCH_PROMPT],
177 defaultTranscriptFile: "fixtures/web_search/claude_transcript.ndjson",
178 queryMode: "streaming",
179 enableTools: true,
180 },
181 claude_background_subagent_after_root: {
182 prompts: [CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT],
183 defaultTranscriptFile:
184 "fixtures/claude_background_subagent_after_root/claude_transcript.ndjson",
185 queryMode: "streaming",
186 enableTools: true,
187 backgroundWakeCounts: [1],
188 },
189 claude_background_task_wake: {
190 prompts: [CLAUDE_BACKGROUND_TASK_WAKE_PROMPT, CLAUDE_BACKGROUND_TASK_WAKE_FOLLOW_UP_PROMPT],
191 defaultTranscriptFile: "fixtures/claude_background_task_wake/claude_transcript.ndjson",
192 queryMode: "streaming",
193 enableTools: true,
194 backgroundWakeCounts: [1, 0],
195 },
196 claude_background_monitor_wake: {
197 prompts: [CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT],
198 defaultTranscriptFile: "fixtures/claude_background_monitor_wake/claude_transcript.ndjson",
199 queryMode: "streaming",
200 enableTools: true,
201 backgroundWakeCounts: [1],
202 },
203 claude_background_subagent_lifecycle: {
204 prompts: [
205 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_LAUNCH_PROMPT,
206 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
207 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
208 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
209 ],
210 defaultTranscriptFile: "fixtures/claude_background_subagent_lifecycle/claude_transcript.ndjson",
211 queryMode: "streaming",
212 enableTools: true,
213 backgroundWakeCounts: [1, 0, 1, 0],
214 },
215 // Each prompt is offered as soon as its turn and the wakes counted here
216 // settle, so a wake queued during a turn (Agent B's "stopped" notice) is
217 // still pending in the CLI when the next prompt arrives, and runs first.
218 claude_background_wake_before_queued_prompt: {
219 prompts: [
220 CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT,
221 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
222 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
223 CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
224 ],
225 defaultTranscriptFile:
226 "fixtures/claude_background_wake_before_queued_prompt/claude_transcript.ndjson",
227 queryMode: "streaming",
228 enableTools: true,
229 backgroundWakeCounts: [1, 0, 1, 0],
230 offerNextPromptImmediately: true,
231 },
232 claude_background_task_interrupt: {
233 prompts: [CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT],
234 defaultTranscriptFile: "fixtures/claude_background_task_interrupt/claude_transcript.ndjson",
235 queryMode: "interrupt",
236 enableTools: true,
237 interruptAfter: "tool_use",
238 interruptAfterToolUses: 2,
239 },
240 claude_nested_background_subagent_wake: {
241 prompts: [CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT],
242 defaultTranscriptFile:
243 "fixtures/claude_nested_background_subagent_wake/claude_transcript.ndjson",
244 queryMode: "streaming",
245 enableTools: true,
246 backgroundWakeCounts: [1],
247 },
248 claude_nested_subagent_model: {
249 prompts: [CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT],
250 defaultTranscriptFile: "fixtures/claude_nested_subagent_model/claude_transcript.ndjson",
251 queryMode: "streaming",
252 enableTools: true,
253 },
254 // Needs the claude.ai Firecrawl connector on the recording account. Claude
255 // Code describes MCP tool uses in an undeclared `tool_use_meta` field.
256 claude_mcp_tool_presentation: {
257 prompts: [CLAUDE_MCP_TOOL_PRESENTATION_PROMPT],
258 defaultTranscriptFile: "fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson",
259 queryMode: "streaming",
260 enableTools: true,
261 },
262 subagent: {
263 prompts: [SUBAGENT_PROMPT],
264 defaultTranscriptFile: "fixtures/subagent/claude_transcript.ndjson",
265 queryMode: "streaming",
266 enableTools: true,
267 },
268 thread_rollback: {
269 prompts: [
270 THREAD_ROLLBACK_FIRST_PROMPT,
271 THREAD_ROLLBACK_SECOND_PROMPT,
272 THREAD_ROLLBACK_AFTER_PROMPT,
273 ],
274 defaultTranscriptFile: "fixtures/thread_rollback/claude_transcript.ndjson",
275 queryMode: "resume_at_cursor",
276 enableTools: true,
277 },
278 thread_fork_native: {
279 prompts: [THREAD_FORK_NATIVE_SOURCE_PROMPT, THREAD_FORK_NATIVE_TARGET_PROMPT],
280 defaultTranscriptFile: "fixtures/thread_fork_native/claude_transcript.ndjson",
281 queryMode: "fork_session",
282 enableTools: true,
283 },
284 thread_fork_native_prior_turn: {
285 prompts: [
286 THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
287 THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
288 THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
289 ],
290 defaultTranscriptFile: "fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson",
291 queryMode: "fork_session_prior_turn",
292 enableTools: true,
293 },
294 thread_fork_native_continue: {
295 prompts: [
296 THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
297 THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
298 THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
299 ],
300 defaultTranscriptFile: "fixtures/thread_fork_native_continue/claude_transcript.ndjson",
301 queryMode: "fork_session_continue",
302 enableTools: true,
303 },
304 thread_fork_native_siblings: {
305 prompts: [
306 THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
307 THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
308 THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
309 ],
310 defaultTranscriptFile: "fixtures/thread_fork_native_siblings/claude_transcript.ndjson",
311 queryMode: "fork_session_siblings",
312 enableTools: true,
313 },
314 thread_merge_back_continue: {
315 prompts: [
316 THREAD_MERGE_BACK_SOURCE_PROMPT,
317 THREAD_MERGE_BACK_FORK_PROMPT,
318 THREAD_MERGE_BACK_HANDOFF_PROMPT,
319 THREAD_MERGE_BACK_RECALL_PROMPT,
320 ],
321 defaultTranscriptFile: "fixtures/thread_merge_back_continue/claude_transcript.ndjson",
322 queryMode: "fork_session_merge_back",
323 enableTools: true,
324 },
325 thread_merge_back_siblings: {
326 prompts: [
327 THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
328 THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
329 THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
330 THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
331 THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
332 THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
333 ],
334 defaultTranscriptFile: "fixtures/thread_merge_back_siblings/claude_transcript.ndjson",
335 queryMode: "fork_session_merge_back_siblings",
336 enableTools: true,
337 },
338} as const;
339
340function readArgValue(name: string): string | undefined {
341 const args = process.argv.slice(2);
342 const index = args.indexOf(name);
343 return index >= 0 ? args[index + 1] : undefined;
344}
345
346function selectedQueryMode(defaultMode: ClaudeRecordingQueryMode): ClaudeRecordingQueryMode {
347 const raw = readArgValue("--query-mode") ?? process.env.T3_CLAUDE_REPLAY_QUERY_MODE;
348 if (raw === undefined) {
349 return defaultMode;
350 }
351 if (
352 raw === "streaming" ||
353 raw === "restart" ||
354 raw === "resume_at_cursor" ||
355 raw === "fork_session" ||
356 raw === "fork_session_prior_turn" ||
357 raw === "fork_session_continue" ||
358 raw === "fork_session_siblings" ||
359 raw === "fork_session_merge_back" ||
360 raw === "fork_session_merge_back_siblings" ||
361 raw === "active_steering" ||
362 raw === "interrupt" ||
363 raw === "interrupt_restart"
364 ) {
365 return raw;
366 }
367 throw new Error(
368 `Unsupported Claude replay query mode '${raw}'. Use 'streaming', 'restart', 'resume_at_cursor', 'fork_session', 'fork_session_prior_turn', 'fork_session_continue', 'fork_session_siblings', 'fork_session_merge_back', 'fork_session_merge_back_siblings', 'active_steering', 'interrupt', or 'interrupt_restart'.`,
369 );
370}
371
372const scenario = readArgValue("--scenario") ?? process.env.T3_CLAUDE_REPLAY_SCENARIO ?? "simple";
373const recording = CLAUDE_RECORDINGS[scenario as keyof typeof CLAUDE_RECORDINGS];
374const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
375
376if (recording === undefined) {
377 throw new Error(`Claude replay fixture '${scenario}' is not configured.`);
378}
379
380const positionalOutputPath = process.argv[2]?.startsWith("--") ? undefined : process.argv[2];
381const path = await Effect.runPromise(
382 Effect.service(Path.Path).pipe(Effect.provide(NodeServices.layer)),
383);
384const outputPath =
385 readArgValue("--out") ??
386 positionalOutputPath ??
387 (await Effect.runPromise(
388 path.fromFileUrl(
389 new URL(
390 `../src/orchestration-v2/testkit/${recording.defaultTranscriptFile}`,
391 import.meta.url,
392 ),
393 ),
394 ));
395
396function encodeTranscriptNdjson(
397 transcript: Awaited<ReturnType<typeof recordClaudeAgentSdkReplayTranscript>>,
398): string {
399 const { entries, ...metadata } = transcript;
400 return [
401 JSON.stringify({ type: "transcript_start", ...metadata }),
402 ...entries.map((entry) => JSON.stringify(entry)),
403 "",
404 ].join("\n");
405}
406
407function joinPath(directory: string, fileName: string): string {
408 return `${directory.replace(/\/+$/u, "")}/${fileName.replace(/^\/+/u, "")}`;
409}
410
411function runFileSystem<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>): Promise<A> {
412 return Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
413}
414
415function selectedPrompts(): ReadonlyArray<string> {
416 if (process.env.T3_CLAUDE_REPLAY_PROMPTS !== undefined) {
417 return process.env.T3_CLAUDE_REPLAY_PROMPTS.split("\n---\n").filter(
418 (prompt) => prompt.length > 0,
419 );
420 }
421 if (process.env.T3_CLAUDE_REPLAY_PROMPT !== undefined) {
422 return [process.env.T3_CLAUDE_REPLAY_PROMPT];
423 }
424 return recording.prompts;
425}
426
427const prompts = selectedPrompts();
428const queryMode = selectedQueryMode(recording.queryMode);
429validateClaudeReplayRecordingSelection({
430 scenario,
431 configuredQueryMode: recording.queryMode,
432 selectedQueryMode: queryMode,
433 configuredPromptCount: recording.prompts.length,
434 selectedPromptCount: prompts.length,
435});
436
437function runtimePolicyForRecording(input: {
438 readonly cwd: string;
439 readonly override?: RuntimePolicyV2Override;
440}): ProviderAdapter.ProviderAdapterV2RuntimePolicy {
441 return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
442 runtimeMode: "full-access",
443 interactionMode: "default",
444 cwd: input.override?.cwd ?? input.cwd,
445 ...(input.override?.approvalPolicy === undefined
446 ? {}
447 : { approvalPolicy: input.override.approvalPolicy }),
448 ...(input.override?.sandboxPolicy === undefined
449 ? {}
450 : { sandboxPolicy: input.override.sandboxPolicy }),
451 ...(input.override?.reasoningEffort === undefined
452 ? {}
453 : { reasoningEffort: input.override.reasoningEffort }),
454 });
455}
456
457async function makeToolCallReadOnlyRecordingWorkspace(): Promise<string> {
458 await runFileSystem(
459 Effect.gen(function* () {
460 const fs = yield* FileSystem.FileSystem;
461 yield* fs.remove(TOOL_CALL_READ_ONLY_WORKSPACE_ROOT, { recursive: true, force: true });
462 yield* fs.makeDirectory(TOOL_CALL_READ_ONLY_WORKSPACE_ROOT, { recursive: true });
463 }),
464 );
465 return TOOL_CALL_READ_ONLY_WORKSPACE_ROOT;
466}
467
468const cwd =
469 process.env.T3_CLAUDE_REPLAY_CWD ??
470 (scenario === "tool_call_read_only"
471 ? await makeToolCallReadOnlyRecordingWorkspace()
472 : await makeCheckpointWorkspace(`claude-agent-sdk-record-${scenario}`));
473const shouldRemoveCwd = process.env.T3_CLAUDE_REPLAY_CWD === undefined;
474
475const expectedAbsentWorkspacePaths =
476 "expectedAbsentWorkspacePaths" in recording ? recording.expectedAbsentWorkspacePaths : [];
477
478// Scenarios that deny a tool call prove the denial by checking the real
479// recording workspace, not just the transcript: the target must be absent
480// before and after the turn, and the verified paths are preserved in the
481// transcript metadata before the temporary workspace is removed.
482async function assertWorkspacePathsAbsent(phase: "before" | "after"): Promise<void> {
483 const presentPaths = await runFileSystem(
484 Effect.gen(function* () {
485 const fs = yield* FileSystem.FileSystem;
486 const present: Array<string> = [];
487 for (const relativePath of expectedAbsentWorkspacePaths) {
488 if (yield* fs.exists(joinPath(cwd, relativePath))) {
489 present.push(relativePath);
490 }
491 }
492 return present;
493 }),
494 );
495 if (presentPaths.length > 0) {
496 throw new Error(
497 `Claude replay fixture '${scenario}' expected ${presentPaths
498 .map((relativePath) => `'${relativePath}'`)
499 .join(
500 ", ",
501 )} to be absent ${phase} the recorded turn, but found in the recording workspace.`,
502 );
503 }
504}
505
506if (shouldRemoveCwd && (scenario === "tool_call_read_only" || scenario === "subagent")) {
507 await runFileSystem(
508 Effect.gen(function* () {
509 const fs = yield* FileSystem.FileSystem;
510 yield* fs.writeFileString(
511 joinPath(cwd, "package.json"),
512 encodeUnknownJsonString({
513 name: "claude-read-only-fixture",
514 private: true,
515 scripts: { typecheck: "tsc --noEmit" },
516 }),
517 );
518 yield* fs.writeFileString(
519 joinPath(cwd, "tsconfig.json"),
520 encodeUnknownJsonString({
521 compilerOptions: {
522 module: "ESNext",
523 strict: true,
524 target: "ES2022",
525 },
526 }),
527 );
528 }),
529 );
530}
531
532try {
533 const runtimePolicy = runtimePolicyForRecording({
534 cwd,
535 ...("runtimePolicyOverride" in recording ? { override: recording.runtimePolicyOverride } : {}),
536 });
537 const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(runtimePolicy);
538
539 await assertWorkspacePathsAbsent("before");
540 const transcript = await recordClaudeAgentSdkReplayTranscript({
541 scenario,
542 prompts,
543 modelSelection: {
544 ...CLAUDE_MODEL_SELECTION,
545 model: process.env.T3_CLAUDE_REPLAY_MODEL ?? CLAUDE_MODEL_SELECTION.model,
546 },
547 cwd,
548 ...(process.env.T3_CLAUDE_REPLAY_SESSION_ID === undefined
549 ? {}
550 : { sessionId: process.env.T3_CLAUDE_REPLAY_SESSION_ID }),
551 queryMode,
552 ...("enableTools" in recording && recording.enableTools === true ? { enableTools: true } : {}),
553 ...(queryPolicy.tools === undefined ? {} : { tools: queryPolicy.tools }),
554 permissionMode: queryPolicy.permissionMode,
555 ...(queryPolicy.allowedTools === undefined ? {} : { allowedTools: queryPolicy.allowedTools }),
556 ...(queryPolicy.allowDangerouslySkipPermissions === undefined
557 ? {}
558 : { allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions }),
559 ...(queryPolicy.installPermissionCallback ? { enablePermissionCallback: true } : {}),
560 ...("permissionDecision" in recording
561 ? { permissionDecision: recording.permissionDecision }
562 : {}),
563 ...("interruptAfter" in recording ? { interruptAfter: recording.interruptAfter } : {}),
564 ...("interruptAfterToolUses" in recording
565 ? { interruptAfterToolUses: recording.interruptAfterToolUses }
566 : {}),
567 ...("backgroundWakeCounts" in recording
568 ? { backgroundWakeCounts: recording.backgroundWakeCounts }
569 : {}),
570 ...("offerNextPromptImmediately" in recording
571 ? { offerNextPromptImmediately: recording.offerNextPromptImmediately }
572 : {}),
573 });
574 await assertWorkspacePathsAbsent("after");
575 const transcriptWithEvidence =
576 expectedAbsentWorkspacePaths.length === 0
577 ? transcript
578 : {
579 ...transcript,
580 metadata: {
581 ...transcript.metadata,
582 verifiedAbsentWorkspacePaths: [...expectedAbsentWorkspacePaths],
583 },
584 };
585 await runFileSystem(
586 Effect.gen(function* () {
587 const fs = yield* FileSystem.FileSystem;
588 yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
589 yield* fs.writeFileString(outputPath, encodeTranscriptNdjson(transcriptWithEvidence));
590 }),
591 );
592 await Effect.runPromise(
593 Console.log(
594 `Wrote ${transcript.entries.length} ${CLAUDE_AGENT_SDK_REPLAY_PROTOCOL} replay entries to ${outputPath}`,
595 ),
596 );
597} finally {
598 if (shouldRemoveCwd) {
599 await runFileSystem(
600 Effect.gen(function* () {
601 const fs = yield* FileSystem.FileSystem;
602 yield* fs.remove(cwd, { recursive: true, force: true });
603 }),
604 );
605 }
606}