| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as Console from "effect/Console"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import { |
| 9 | recordClaudeAgentSdkReplayTranscript, |
| 10 | CLAUDE_AGENT_SDK_REPLAY_PROTOCOL, |
| 11 | } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts"; |
| 12 | import { claudeRuntimeQueryPolicyForRuntimePolicy } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 13 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 14 | import type { RuntimePolicyV2Override } from "../src/orchestration-v2/RuntimePolicy.ts"; |
| 15 | import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 16 | import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; |
| 17 | import { |
| 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"; |
| 60 | import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts"; |
| 61 | import { |
| 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"; |
| 67 | import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/input.ts"; |
| 68 | import { CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/input.ts"; |
| 69 | import { CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/input.ts"; |
| 70 | import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts"; |
| 71 | import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/input.ts"; |
| 72 | import { CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts"; |
| 73 | import { |
| 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"; |
| 77 | import { |
| 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"; |
| 82 | import { |
| 83 | validateClaudeReplayRecordingSelection, |
| 84 | type ClaudeRecordingQueryMode, |
| 85 | } from "./claudeReplayRecordingConfig.ts"; |
| 86 | |
| 87 | const 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 | |
| 340 | function 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 | |
| 346 | function 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 | |
| 372 | const scenario = readArgValue("--scenario") ?? process.env.T3_CLAUDE_REPLAY_SCENARIO ?? "simple"; |
| 373 | const recording = CLAUDE_RECORDINGS[scenario as keyof typeof CLAUDE_RECORDINGS]; |
| 374 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 375 | |
| 376 | if (recording === undefined) { |
| 377 | throw new Error(`Claude replay fixture '${scenario}' is not configured.`); |
| 378 | } |
| 379 | |
| 380 | const positionalOutputPath = process.argv[2]?.startsWith("--") ? undefined : process.argv[2]; |
| 381 | const path = await Effect.runPromise( |
| 382 | Effect.service(Path.Path).pipe(Effect.provide(NodeServices.layer)), |
| 383 | ); |
| 384 | const 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 | |
| 396 | function 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 | |
| 407 | function joinPath(directory: string, fileName: string): string { |
| 408 | return `${directory.replace(/\/+$/u, "")}/${fileName.replace(/^\/+/u, "")}`; |
| 409 | } |
| 410 | |
| 411 | function runFileSystem<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>): Promise<A> { |
| 412 | return Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); |
| 413 | } |
| 414 | |
| 415 | function 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 | |
| 427 | const prompts = selectedPrompts(); |
| 428 | const queryMode = selectedQueryMode(recording.queryMode); |
| 429 | validateClaudeReplayRecordingSelection({ |
| 430 | scenario, |
| 431 | configuredQueryMode: recording.queryMode, |
| 432 | selectedQueryMode: queryMode, |
| 433 | configuredPromptCount: recording.prompts.length, |
| 434 | selectedPromptCount: prompts.length, |
| 435 | }); |
| 436 | |
| 437 | function 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 | |
| 457 | async 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 | |
| 468 | const 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}`)); |
| 473 | const shouldRemoveCwd = process.env.T3_CLAUDE_REPLAY_CWD === undefined; |
| 474 | |
| 475 | const 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. |
| 482 | async 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 | |
| 506 | if (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 | |
| 532 | try { |
| 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 | } |