1import {
2 forkSession,
3 query,
4 type CanUseTool,
5 type PermissionResult,
6 type SDKAssistantMessage,
7 type SDKMessage,
8 type SDKUserMessage,
9} from "@anthropic-ai/claude-agent-sdk";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import { SpawnExecutableResolution } from "@t3tools/shared/shell";
13import {
14 ProviderReplayEntry,
15 type ModelSelection,
16 type ProviderApprovalDecision,
17 type ProviderReplayTranscript,
18} from "@t3tools/contracts";
19import * as Cause from "effect/Cause";
20import * as Crypto from "effect/Crypto";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
23import * as Exit from "effect/Exit";
24import * as Layer from "effect/Layer";
25import * as Schema from "effect/Schema";
26import * as Stream from "effect/Stream";
27
28import * as ServerConfig from "../../config.ts";
29import {
30 isWindowsClaudeLauncherShimPath,
31 resolveClaudeSdkExecutablePath,
32} from "../../provider/Drivers/ClaudeExecutable.ts";
33import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
35import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver";
36import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
37import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
38import {
39 makeReplayServerConfig,
40 type OrchestratorV2ProviderReplayHarness,
41} from "../testkit/ProviderReplayHarness.ts";
42import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate";
43
44export const CLAUDE_AGENT_SDK_REPLAY_PROTOCOL = "claude-agent-sdk.query" as const;
45/**
46 * Replay label of the result that ends the Nth (1-based) background wake turn
47 * of a recording, so a replay gate can hold it.
48 */
49export const claudeBackgroundWakeResultLabel = (wakeNumber: number) =>
50 `result:background-wake:${wakeNumber}`;
51
52const ClaudeAgentSdkReplayTranscript = Schema.Struct({
53 provider: Schema.Literal(ClaudeAdapterV2.CLAUDE_PROVIDER),
54 protocol: Schema.Literal(CLAUDE_AGENT_SDK_REPLAY_PROTOCOL),
55 version: Schema.String,
56 scenario: Schema.String,
57 metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
58 entries: Schema.Array(ProviderReplayEntry),
59});
60type ClaudeAgentSdkReplayTranscript = typeof ClaudeAgentSdkReplayTranscript.Type;
61
62export class ClaudeReplayTranscriptDecodeError extends Schema.TaggedError<ClaudeReplayTranscriptDecodeError>()(
63 "ClaudeReplayTranscriptDecodeError",
64 {
65 driver: Schema.optional(Schema.String),
66 protocol: Schema.optional(Schema.String),
67 scenario: Schema.optional(Schema.String),
68 cause: Schema.Defect(),
69 },
70) {
71 override get message(): string {
72 return `Failed to decode Claude Agent SDK replay transcript for scenario ${this.scenario ?? "<unknown>"}.`;
73 }
74}
75
76export class ClaudeReplayExhaustedError extends Schema.TaggedError<ClaudeReplayExhaustedError>()(
77 "ClaudeReplayExhaustedError",
78 {
79 scenario: Schema.String,
80 cursor: Schema.Number,
81 actual: Schema.Unknown,
82 },
83) {
84 override get message(): string {
85 return `Claude Agent SDK replay transcript exhausted before outbound frame ${this.cursor} in scenario ${this.scenario}.`;
86 }
87}
88
89export class ClaudeReplayUnexpectedOutboundError extends Schema.TaggedError<ClaudeReplayUnexpectedOutboundError>()(
90 "ClaudeReplayUnexpectedOutboundError",
91 {
92 scenario: Schema.String,
93 cursor: Schema.Number,
94 expectedType: Schema.String,
95 actual: Schema.Unknown,
96 },
97) {
98 override get message(): string {
99 return `Unexpected outbound Claude Agent SDK frame at replay cursor ${this.cursor} in scenario ${this.scenario}.`;
100 }
101}
102
103export class ClaudeReplayFrameMismatchError extends Schema.TaggedError<ClaudeReplayFrameMismatchError>()(
104 "ClaudeReplayFrameMismatchError",
105 {
106 scenario: Schema.String,
107 cursor: Schema.Number,
108 label: Schema.optional(Schema.String),
109 expected: Schema.Unknown,
110 actual: Schema.Unknown,
111 },
112) {
113 override get message(): string {
114 return `Outbound Claude Agent SDK frame did not match replay cursor ${this.cursor} in scenario ${this.scenario}.`;
115 }
116}
117
118export class ClaudeReplayRuntimeExitError extends Schema.TaggedError<ClaudeReplayRuntimeExitError>()(
119 "ClaudeReplayRuntimeExitError",
120 {
121 scenario: Schema.String,
122 cursor: Schema.Number,
123 status: Schema.Literals(["error", "cancelled"]),
124 error: Schema.optional(Schema.Unknown),
125 },
126) {
127 override get message(): string {
128 return `Claude Agent SDK replay exited with status ${this.status} at cursor ${this.cursor} in scenario ${this.scenario}.`;
129 }
130}
131
132export class ClaudeReplayIncompleteError extends Schema.TaggedError<ClaudeReplayIncompleteError>()(
133 "ClaudeReplayIncompleteError",
134 {
135 scenario: Schema.String,
136 cursor: Schema.Number,
137 remaining: Schema.Number,
138 },
139) {
140 override get message(): string {
141 return `Claude Agent SDK replay ended with ${this.remaining} unconsumed entries in scenario ${this.scenario}.`;
142 }
143}
144
145export class ClaudeReplayDriverError extends Schema.TaggedError<ClaudeReplayDriverError>()(
146 "ClaudeReplayDriverError",
147 {
148 scenario: Schema.String,
149 cause: Schema.Defect(),
150 },
151) {
152 override get message(): string {
153 return `Claude Agent SDK replay driver failed in scenario ${this.scenario}.`;
154 }
155}
156
157export const ClaudeAgentSdkReplayError = Schema.Union([
158 ClaudeReplayTranscriptDecodeError,
159 ClaudeReplayExhaustedError,
160 ClaudeReplayUnexpectedOutboundError,
161 ClaudeReplayFrameMismatchError,
162 ClaudeReplayRuntimeExitError,
163 ClaudeReplayIncompleteError,
164 ClaudeReplayDriverError,
165]);
166export type ClaudeAgentSdkReplayError = typeof ClaudeAgentSdkReplayError.Type;
167export const ClaudeOrchestratorReplayHarnessError = Schema.Union([
168 ClaudeAgentSdkReplayError,
169 ProviderAdapterDriverCreateError,
170]);
171export type ClaudeOrchestratorReplayHarnessError = typeof ClaudeOrchestratorReplayHarnessError.Type;
172
173interface ClaudeQueryOpenFrame {
174 readonly type: "query.open";
175 readonly options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions;
176}
177
178interface ClaudePromptOfferFrame {
179 readonly type: "prompt.offer";
180 readonly message: SDKUserMessage;
181}
182
183interface ClaudeQuerySetModelFrame {
184 readonly type: "query.set_model";
185 readonly model: string;
186}
187
188interface ClaudeQuerySetPermissionModeFrame {
189 readonly type: "query.set_permission_mode";
190 readonly mode: string;
191}
192
193interface ClaudeQueryInterruptFrame {
194 readonly type: "query.interrupt";
195}
196
197interface ClaudePermissionRequestFrame {
198 readonly type: "permission.request";
199 readonly toolName: string;
200 readonly input: Record<string, unknown>;
201 readonly options: {
202 readonly suggestions?: Parameters<CanUseTool>[2]["suggestions"];
203 readonly blockedPath?: string;
204 readonly decisionReason?: string;
205 readonly title?: string;
206 readonly displayName?: string;
207 readonly description?: string;
208 readonly toolUseID: string;
209 readonly agentID?: string;
210 };
211}
212
213interface ClaudePermissionResponseFrame {
214 readonly type: "permission.response";
215 readonly result: PermissionResult;
216}
217
218interface ClaudeSessionForkFrame {
219 readonly type: "session.fork";
220 readonly sessionId: string;
221 readonly options: {
222 readonly dir?: string;
223 readonly upToMessageId?: string;
224 readonly title?: string;
225 };
226}
227
228interface ClaudeSessionForkedFrame {
229 readonly type: "session.forked";
230 readonly sessionId: string;
231}
232
233interface ClaudeSubagentLookupFrame {
234 readonly type: "subagent.lookup";
235 readonly sessionId: string;
236 readonly agentId: string;
237}
238
239interface ClaudeSubagentFoundFrame {
240 readonly type: "subagent.found";
241 readonly toolUseId: string | null;
242}
243
244type ClaudeOutboundFrame =
245 | { readonly type: "query.stop_task"; readonly taskId: string }
246 | ClaudeQueryOpenFrame
247 | ClaudePromptOfferFrame
248 | ClaudeQuerySetModelFrame
249 | {
250 readonly type: "query.apply_flag_settings";
251 readonly settings: Parameters<
252 ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]
253 >[0];
254 }
255 | ClaudeQuerySetPermissionModeFrame
256 | ClaudeQueryInterruptFrame
257 | ClaudePermissionResponseFrame
258 | ClaudeSessionForkFrame
259 | ClaudeSubagentLookupFrame;
260
261interface ClaudeQueryRunner {
262 readonly open: (
263 input: ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput,
264 ) => ClaudeAdapterV2.ClaudeAgentSdkQuerySession;
265 readonly forkSession: (
266 input: ClaudeAdapterV2.ClaudeAgentSdkSessionForkInput,
267 ) => ClaudeSessionForkedFrame;
268 readonly subagentLaunchToolUseId: (
269 input: ClaudeAdapterV2.ClaudeAgentSdkSubagentLookupInput,
270 ) => string | null;
271 readonly assertComplete: () => void;
272}
273
274function stableStringify(value: unknown): string {
275 if (Array.isArray(value)) {
276 return `[${value.map(stableStringify).join(",")}]`;
277 }
278 if (typeof value === "object" && value !== null) {
279 const record = value as Record<string, unknown>;
280 return `{${Object.keys(record)
281 .toSorted()
282 .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
283 .join(",")}}`;
284 }
285 return JSON.stringify(value);
286}
287
288function normalizeContextHandoffText(value: string): string {
289 if (!value.startsWith("Context handoff (")) {
290 return value;
291 }
292 const userMessageMarker = "\n\nUser message:\n";
293 const userMessageIndex = value.indexOf(userMessageMarker);
294 const headerEndIndex = value.indexOf(":\n");
295 if (headerEndIndex === -1 || userMessageIndex === -1 || headerEndIndex >= userMessageIndex) {
296 return value;
297 }
298 return `${value.slice(0, headerEndIndex + 2)}<dynamic-summary>${value.slice(userMessageIndex)}`;
299}
300
301function normalizeReplayFrame(value: unknown): unknown {
302 if (typeof value === "string") {
303 return normalizeContextHandoffText(value);
304 }
305 if (Array.isArray(value)) {
306 return value.map(normalizeReplayFrame);
307 }
308 if (typeof value !== "object" || value === null) {
309 return value;
310 }
311 return Object.fromEntries(
312 Object.entries(value).map(([key, entry]) => [key, normalizeReplayFrame(entry)]),
313 );
314}
315
316function sameFrame(left: unknown, right: unknown): boolean {
317 return (
318 stableStringify(normalizeReplayFrame(left)) === stableStringify(normalizeReplayFrame(right))
319 );
320}
321
322function isClaudeSdkReplayMessage(frame: unknown): frame is SDKMessage {
323 if (typeof frame !== "object" || frame === null) {
324 return false;
325 }
326 const type = Reflect.get(frame, "type");
327 return (
328 type === "assistant" ||
329 type === "user" ||
330 type === "result" ||
331 type === "system" ||
332 type === "stream_event" ||
333 type === "rate_limit_event" ||
334 // Undeclared in the SDK types: queued/started/completed for each prompt
335 // that carries a uuid.
336 type === "command_lifecycle"
337 );
338}
339
340function sdkMessageFromReplayFrame(frame: unknown): SDKMessage {
341 if (!isClaudeSdkReplayMessage(frame)) {
342 throw new Error("Replay frame is not a Claude Agent SDK message.");
343 }
344 return frame;
345}
346
347function isClaudePermissionRequestFrame(frame: unknown): frame is ClaudePermissionRequestFrame {
348 const options =
349 typeof frame === "object" && frame !== null ? Reflect.get(frame, "options") : undefined;
350 return (
351 typeof frame === "object" &&
352 frame !== null &&
353 Reflect.get(frame, "type") === "permission.request" &&
354 typeof Reflect.get(frame, "toolName") === "string" &&
355 typeof Reflect.get(frame, "input") === "object" &&
356 Reflect.get(frame, "input") !== null &&
357 typeof options === "object" &&
358 options !== null &&
359 typeof Reflect.get(options, "toolUseID") === "string"
360 );
361}
362
363function makeClaudePermissionResponseFrame(
364 result: PermissionResult,
365): ClaudePermissionResponseFrame {
366 return {
367 type: "permission.response",
368 result,
369 };
370}
371
372function permissionRequestOptionsFromFrame(
373 frame: ClaudePermissionRequestFrame,
374 signal: AbortSignal,
375): Parameters<CanUseTool>[2] {
376 const options = frame.options;
377 return {
378 signal,
379 ...(options.suggestions === undefined ? {} : { suggestions: options.suggestions }),
380 ...(options.blockedPath === undefined ? {} : { blockedPath: options.blockedPath }),
381 ...(options.decisionReason === undefined ? {} : { decisionReason: options.decisionReason }),
382 ...(options.title === undefined ? {} : { title: options.title }),
383 ...(options.displayName === undefined ? {} : { displayName: options.displayName }),
384 ...(options.description === undefined ? {} : { description: options.description }),
385 toolUseID: options.toolUseID,
386 ...(options.agentID === undefined ? {} : { agentID: options.agentID }),
387 requestId: options.toolUseID,
388 };
389}
390
391function unresolvedCursorSignal(): void {}
392
393function makeCursorSignal(): {
394 readonly promise: Promise<void>;
395 readonly resolve: () => void;
396} {
397 let resolve: () => void = unresolvedCursorSignal;
398 const promise = new Promise<void>((onResolve) => {
399 resolve = onResolve;
400 });
401 return { promise, resolve };
402}
403
404function waitForCursorAdvance(promise: Promise<void>, signal: AbortSignal): Promise<void> {
405 if (signal.aborted) {
406 return Promise.resolve();
407 }
408 return new Promise<void>((resolve) => {
409 let waiting = true;
410 const finish = () => {
411 if (!waiting) {
412 return;
413 }
414 waiting = false;
415 signal.removeEventListener("abort", finish);
416 resolve();
417 };
418 signal.addEventListener("abort", finish, { once: true });
419 void promise.then(finish);
420 if (signal.aborted) {
421 finish();
422 }
423 });
424}
425
426async function waitForReplayDelay(afterMs: number, signal: AbortSignal): Promise<void> {
427 const exit = await Effect.runPromiseExit(Effect.sleep(Duration.millis(afterMs)), { signal });
428 if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
429 throw Cause.squash(exit.cause);
430 }
431}
432
433function stableClaudeQueryOptions(
434 options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions,
435): ClaudeAdapterV2.ClaudeAgentSdkQueryOptions {
436 const stable = {
437 model: options.model,
438 tools: options.tools,
439 permissionMode: options.permissionMode,
440 ...(options.allowedTools === undefined ? {} : { allowedTools: options.allowedTools }),
441 ...(options.disallowedTools === undefined ? {} : { disallowedTools: options.disallowedTools }),
442 ...(options.settings === undefined ? {} : { settings: options.settings }),
443 ...(options.allowDangerouslySkipPermissions === true
444 ? { allowDangerouslySkipPermissions: true }
445 : {}),
446 ...(options.resumeSessionAt === undefined ? {} : { resumeSessionAt: options.resumeSessionAt }),
447 ...(options.forkSession === true ? { forkSession: true } : {}),
448 };
449 return options.resume === undefined
450 ? { ...stable, sessionId: options.sessionId }
451 : { ...stable, resume: options.resume };
452}
453
454function makeClaudeQueryOpenFrame(
455 input: Pick<ClaudeAdapterV2.ClaudeAgentSdkQueryOpenInput, "options">,
456): ClaudeQueryOpenFrame {
457 return {
458 type: "query.open",
459 options: stableClaudeQueryOptions(input.options),
460 };
461}
462
463function makeClaudePromptOfferFrame(message: SDKUserMessage): ClaudePromptOfferFrame {
464 return {
465 type: "prompt.offer",
466 message,
467 };
468}
469
470function makeClaudeSessionForkFrame(
471 input: ClaudeAdapterV2.ClaudeAgentSdkSessionForkInput,
472 scenario: string,
473): ClaudeSessionForkFrame {
474 return {
475 type: "session.fork",
476 sessionId: input.sessionId,
477 options: {
478 ...(input.options.dir === undefined ? {} : { dir: sanitizedReplayCwd(scenario) }),
479 ...(input.options.upToMessageId === undefined
480 ? {}
481 : { upToMessageId: input.options.upToMessageId }),
482 ...(input.options.title === undefined ? {} : { title: input.options.title }),
483 },
484 };
485}
486
487function makeReplayQueryRunner(
488 transcript: ClaudeAgentSdkReplayTranscript,
489 replayOptions: { readonly replayGate?: ProviderReplayGate } = {},
490): ClaudeQueryRunner {
491 let cursor = 0;
492 let failure: ClaudeAgentSdkReplayError | null = null;
493 let cursorAdvanced = makeCursorSignal();
494 const iteratorAbortControllers = new Set<AbortController>();
495
496 const abortReplayIterators = () => {
497 for (const abortController of iteratorAbortControllers) {
498 abortController.abort();
499 }
500 };
501
502 const fail = (error: ClaudeAgentSdkReplayError): never => {
503 failure = error;
504 abortReplayIterators();
505 replayOptions.replayGate?.releaseAll();
506 cursorAdvanced.resolve();
507 throw error;
508 };
509
510 const throwIfFailed = () => {
511 if (failure !== null) {
512 throw failure;
513 }
514 };
515
516 const advance = () => {
517 cursor += 1;
518 const signal = cursorAdvanced;
519 cursorAdvanced = makeCursorSignal();
520 signal.resolve();
521 };
522
523 async function* replayMessages(
524 options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions,
525 signal: AbortSignal,
526 ): AsyncGenerator<SDKMessage, void> {
527 while (true) {
528 throwIfFailed();
529
530 const entry = transcript.entries[cursor];
531 if (entry === undefined) {
532 return;
533 }
534
535 if (entry.type === "emit_inbound") {
536 if (replayOptions.replayGate !== undefined) {
537 await replayOptions.replayGate.beforeEmit(entry.label, signal);
538 throwIfFailed();
539 if (signal.aborted) {
540 return;
541 }
542 }
543 if (entry.afterMs !== undefined && entry.afterMs > 0) {
544 await waitForReplayDelay(entry.afterMs, signal);
545 throwIfFailed();
546 if (signal.aborted) {
547 return;
548 }
549 }
550 if (isClaudePermissionRequestFrame(entry.frame)) {
551 const request = entry.frame;
552 const invokeCanUseTool = options.canUseTool;
553 if (invokeCanUseTool === undefined) {
554 const error = new ClaudeReplayUnexpectedOutboundError({
555 scenario: transcript.scenario,
556 cursor,
557 expectedType: "permission.request",
558 actual: request,
559 });
560 return fail(error);
561 }
562 advance();
563 let result: PermissionResult | null;
564 try {
565 result = await invokeCanUseTool(
566 request.toolName,
567 request.input,
568 permissionRequestOptionsFromFrame(request, signal),
569 );
570 } catch (cause) {
571 throwIfFailed();
572 if (signal.aborted) {
573 return;
574 }
575 throw cause;
576 }
577 throwIfFailed();
578 if (signal.aborted) {
579 return;
580 }
581 if (result === null) {
582 const error = new ClaudeReplayUnexpectedOutboundError({
583 scenario: transcript.scenario,
584 cursor,
585 expectedType: "permission.response",
586 actual: null,
587 });
588 return fail(error);
589 }
590 assertNextOutboundFrame(makeClaudePermissionResponseFrame(result));
591 continue;
592 }
593 advance();
594 yield sdkMessageFromReplayFrame(withReplayedPromptUuids(entry.frame));
595 continue;
596 }
597
598 if (entry.type === "runtime_exit") {
599 advance();
600 if (entry.status === "success") {
601 return;
602 }
603 fail(
604 new ClaudeReplayRuntimeExitError({
605 scenario: transcript.scenario,
606 cursor: cursor - 1,
607 status: entry.status,
608 ...(entry.error === undefined ? {} : { error: entry.error }),
609 }),
610 );
611 }
612
613 if (entry.type === "expect_outbound") {
614 await waitForCursorAdvance(cursorAdvanced.promise, signal);
615 throwIfFailed();
616 if (signal.aborted) {
617 return;
618 }
619 continue;
620 }
621 }
622 }
623
624 const replayMessagesWithGateCleanup = (
625 options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions,
626 ): AsyncIterable<SDKMessage> => ({
627 [Symbol.asyncIterator]: () => {
628 const abortController = new AbortController();
629 iteratorAbortControllers.add(abortController);
630 const iterator = replayMessages(options, abortController.signal);
631 return {
632 next: async () => {
633 try {
634 const result = await iterator.next();
635 if (result.done) {
636 iteratorAbortControllers.delete(abortController);
637 }
638 return result;
639 } catch (cause) {
640 iteratorAbortControllers.delete(abortController);
641 throw cause;
642 }
643 },
644 return: async () => {
645 abortController.abort();
646 replayOptions.replayGate?.releaseAll();
647 try {
648 return await iterator.return();
649 } finally {
650 iteratorAbortControllers.delete(abortController);
651 }
652 },
653 };
654 },
655 });
656
657 // Prompt uuids are derived from ids that differ between the recording and
658 // a replay run, so a matched prompt offer maps the recorded uuid to the
659 // replayed one, and inbound frames echoing or acknowledging it are
660 // rewritten to match.
661 const promptUuidReplacements = new Map<string, string>();
662 const replayedPromptUuid = (value: string): string => promptUuidReplacements.get(value) ?? value;
663 const withReplayedPromptUuids = (frame: unknown): unknown => {
664 if (promptUuidReplacements.size === 0 || typeof frame !== "object" || frame === null) {
665 return frame;
666 }
667 const uuid: unknown = Reflect.get(frame, "user_message_uuid");
668 const uuids: unknown = Reflect.get(frame, "user_message_uuids");
669 const commandUuid: unknown = Reflect.get(frame, "command_uuid");
670 if (typeof uuid !== "string" && !Array.isArray(uuids) && typeof commandUuid !== "string") {
671 return frame;
672 }
673 return {
674 ...frame,
675 ...(typeof commandUuid === "string" ? { command_uuid: replayedPromptUuid(commandUuid) } : {}),
676 ...(typeof uuid === "string" ? { user_message_uuid: replayedPromptUuid(uuid) } : {}),
677 ...(Array.isArray(uuids)
678 ? {
679 user_message_uuids: uuids.map((entry) =>
680 typeof entry === "string" ? replayedPromptUuid(entry) : entry,
681 ),
682 }
683 : {}),
684 };
685 };
686 const promptOfferUuid = (frame: unknown): string | undefined => {
687 if (
688 typeof frame !== "object" ||
689 frame === null ||
690 Reflect.get(frame, "type") !== "prompt.offer"
691 ) {
692 return undefined;
693 }
694 const message: unknown = Reflect.get(frame, "message");
695 const uuid: unknown =
696 typeof message === "object" && message !== null ? Reflect.get(message, "uuid") : undefined;
697 return typeof uuid === "string" ? uuid : undefined;
698 };
699 const withoutPromptOfferUuid = (frame: unknown): unknown => {
700 if (promptOfferUuid(frame) === undefined || typeof frame !== "object" || frame === null) {
701 return frame;
702 }
703 const message = Reflect.get(frame, "message") as Record<string, unknown>;
704 const { uuid: _uuid, ...rest } = message;
705 return { ...frame, message: rest };
706 };
707
708 const assertNextOutboundFrame = (actual: ClaudeOutboundFrame) => {
709 if (failure !== null) {
710 throw failure;
711 }
712 const entry = transcript.entries[cursor];
713 if (entry === undefined) {
714 return fail(
715 new ClaudeReplayExhaustedError({
716 scenario: transcript.scenario,
717 cursor,
718 actual,
719 }),
720 );
721 }
722 if (entry.type !== "expect_outbound") {
723 return fail(
724 new ClaudeReplayUnexpectedOutboundError({
725 scenario: transcript.scenario,
726 cursor,
727 expectedType: entry.type,
728 actual,
729 }),
730 );
731 }
732
733 const expected = entry.frame;
734 const expectedUuid = promptOfferUuid(expected);
735 const actualUuid = promptOfferUuid(actual);
736 // Recordings made before prompts carried a uuid simply lack one.
737 if (
738 !sameFrame(withoutPromptOfferUuid(expected), withoutPromptOfferUuid(actual)) ||
739 (expectedUuid !== undefined && actualUuid === undefined)
740 ) {
741 fail(
742 new ClaudeReplayFrameMismatchError({
743 scenario: transcript.scenario,
744 cursor,
745 ...(entry.label === undefined ? {} : { label: entry.label }),
746 expected,
747 actual,
748 }),
749 );
750 }
751 if (expectedUuid !== undefined && actualUuid !== undefined) {
752 promptUuidReplacements.set(expectedUuid, actualUuid);
753 }
754
755 advance();
756 };
757
758 // The recorded reply to a one-shot session call (fork, subagent lookup).
759 const assertNextReplyFrame = <Frame extends ClaudeSessionForkedFrame | ClaudeSubagentFoundFrame>(
760 type: Frame["type"],
761 isValid: (frame: object) => boolean,
762 ): Frame => {
763 const entry = transcript.entries[cursor];
764 if (entry === undefined) {
765 return fail(
766 new ClaudeReplayExhaustedError({
767 scenario: transcript.scenario,
768 cursor,
769 actual: { type },
770 }),
771 );
772 }
773 if (entry.type !== "emit_inbound") {
774 return fail(
775 new ClaudeReplayUnexpectedOutboundError({
776 scenario: transcript.scenario,
777 cursor,
778 expectedType: entry.type,
779 actual: { type },
780 }),
781 );
782 }
783 if (
784 typeof entry.frame !== "object" ||
785 entry.frame === null ||
786 Reflect.get(entry.frame, "type") !== type ||
787 !isValid(entry.frame)
788 ) {
789 return fail(
790 new ClaudeReplayFrameMismatchError({
791 scenario: transcript.scenario,
792 cursor,
793 expected: { type },
794 actual: entry.frame,
795 }),
796 );
797 }
798
799 const frame = entry.frame as Frame;
800 advance();
801 return frame;
802 };
803
804 const replayEffect = (tryEffect: () => void) =>
805 Effect.try({
806 try: tryEffect,
807 catch: (cause) => replayQueryRunnerError(transcript, cause),
808 });
809
810 return {
811 open: (input) => {
812 assertNextOutboundFrame(makeClaudeQueryOpenFrame(input));
813 return {
814 messages: Stream.fromAsyncIterable(replayMessagesWithGateCleanup(input.options), (cause) =>
815 replayQueryRunnerError(transcript, cause),
816 ),
817 offer: (message) =>
818 replayEffect(() => {
819 assertNextOutboundFrame(makeClaudePromptOfferFrame(message));
820 }),
821 setModel: (model) =>
822 replayEffect(() => {
823 assertNextOutboundFrame({
824 type: "query.set_model",
825 model,
826 });
827 }),
828 applyFlagSettings: (settings) =>
829 replayEffect(() => {
830 assertNextOutboundFrame({
831 type: "query.apply_flag_settings",
832 settings,
833 });
834 }),
835 setPermissionMode: (mode) =>
836 replayEffect(() => {
837 assertNextOutboundFrame({
838 type: "query.set_permission_mode",
839 mode,
840 });
841 }),
842 stopTask: (taskId) =>
843 replayEffect(() => {
844 assertNextOutboundFrame({ type: "query.stop_task", taskId });
845 }),
846 interrupt: replayEffect(() => {
847 assertNextOutboundFrame({ type: "query.interrupt" });
848 }),
849 close: Effect.void,
850 };
851 },
852 forkSession: (input) => {
853 assertNextOutboundFrame(makeClaudeSessionForkFrame(input, transcript.scenario));
854 return assertNextReplyFrame<ClaudeSessionForkedFrame>(
855 "session.forked",
856 (frame) => typeof Reflect.get(frame, "sessionId") === "string",
857 );
858 },
859 subagentLaunchToolUseId: (input) => {
860 assertNextOutboundFrame({
861 type: "subagent.lookup",
862 sessionId: input.sessionId,
863 agentId: input.agentId,
864 });
865 return assertNextReplyFrame<ClaudeSubagentFoundFrame>("subagent.found", (frame) => {
866 const toolUseId = Reflect.get(frame, "toolUseId");
867 return toolUseId === null || typeof toolUseId === "string";
868 }).toolUseId;
869 },
870 assertComplete: () => {
871 if (failure !== null) {
872 throw failure;
873 }
874 if (cursor !== transcript.entries.length) {
875 replayOptions.replayGate?.releaseAll();
876 throw new ClaudeReplayIncompleteError({
877 scenario: transcript.scenario,
878 cursor,
879 remaining: transcript.entries.length - cursor,
880 });
881 }
882 },
883 };
884}
885
886function metadataFromTranscript(transcript: ProviderReplayTranscript): {
887 readonly provider?: string;
888 readonly protocol?: string;
889 readonly scenario?: string;
890} {
891 return {
892 provider: transcript.provider,
893 protocol: transcript.protocol,
894 scenario: transcript.scenario,
895 };
896}
897
898function nativeSessionIdFor(transcript: ClaudeAgentSdkReplayTranscript): string {
899 const metadataSessionId = transcript.metadata?.nativeSessionId;
900 return typeof metadataSessionId === "string"
901 ? metadataSessionId
902 : "00000000-0000-4000-8000-000000000000";
903}
904
905const isClaudeAgentSdkQueryRunnerError = Schema.is(ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError);
906const isClaudeAgentSdkReplayError = Schema.is(ClaudeAgentSdkReplayError);
907
908function replayQueryRunnerError(
909 transcript: ClaudeAgentSdkReplayTranscript,
910 cause: unknown,
911): ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError {
912 if (isClaudeAgentSdkQueryRunnerError(cause)) {
913 return cause;
914 }
915 const replayCause = isClaudeAgentSdkReplayError(cause)
916 ? cause
917 : new ClaudeReplayDriverError({ scenario: transcript.scenario, cause });
918 return new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
919 cause: replayCause,
920 method: `replay-scenario:${transcript.scenario}`,
921 });
922}
923
924const makeClaudeAgentSdkReplayQueryRunner = Effect.fn("ClaudeAgentSdkReplayQueryRunner.layer")(
925 function* (
926 transcript: ClaudeAgentSdkReplayTranscript,
927 options: { readonly replayGate?: ProviderReplayGate } = {},
928 ) {
929 const queryRunner = makeReplayQueryRunner(transcript, options);
930 yield* Effect.addFinalizer(() =>
931 Effect.sync(() => {
932 queryRunner.assertComplete();
933 }),
934 );
935
936 return replayQueryRunnerService(transcript, queryRunner);
937 },
938);
939
940function replayQueryRunnerService(
941 transcript: ClaudeAgentSdkReplayTranscript,
942 queryRunner: ClaudeQueryRunner,
943): ClaudeAdapterV2.ClaudeAgentSdkQueryRunner["Service"] {
944 const replay = <A>(run: () => A) =>
945 Effect.try({
946 try: run,
947 catch: (cause) => replayQueryRunnerError(transcript, cause),
948 });
949 return ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.of({
950 allocateSessionId: Effect.succeed(nativeSessionIdFor(transcript)),
951 open: (input) => replay(() => queryRunner.open(input)),
952 forkSession: (input) => replay(() => queryRunner.forkSession(input)),
953 subagentLaunchToolUseId: (input) => replay(() => queryRunner.subagentLaunchToolUseId(input)),
954 assertComplete: replay(() => queryRunner.assertComplete()),
955 });
956}
957
958function layerClaudeAgentSdkReplayQueryRunner(
959 transcript: ClaudeAgentSdkReplayTranscript,
960 options: { readonly replayGate?: ProviderReplayGate } = {},
961): Layer.Layer<ClaudeAdapterV2.ClaudeAgentSdkQueryRunner> {
962 return Layer.effect(
963 ClaudeAdapterV2.ClaudeAgentSdkQueryRunner,
964 makeClaudeAgentSdkReplayQueryRunner(transcript, options),
965 );
966}
967
968function layerClaudeAgentSdkReplay(
969 transcript: ClaudeAgentSdkReplayTranscript,
970 options: {
971 readonly replayGate?: ProviderReplayGate;
972 // Shared across runtimes; its owner asserts completion.
973 readonly queryRunner?: ClaudeQueryRunner;
974 } = {},
975): Layer.Layer<ClaudeAdapterV2.ClaudeAgentSdkQueryRunner> {
976 if (options.queryRunner !== undefined) {
977 return Layer.succeed(
978 ClaudeAdapterV2.ClaudeAgentSdkQueryRunner,
979 replayQueryRunnerService(transcript, options.queryRunner),
980 );
981 }
982 const queryRunner = makeReplayQueryRunner(transcript, options);
983 return Layer.effect(
984 ClaudeAdapterV2.ClaudeAgentSdkQueryRunner,
985 Effect.gen(function* () {
986 yield* Effect.addFinalizer(() =>
987 Effect.sync(() => {
988 queryRunner.assertComplete();
989 }),
990 );
991 return replayQueryRunnerService(transcript, queryRunner);
992 }),
993 );
994}
995
996function layerClaudeProviderAdapterRegistryReplay(
997 transcript: ClaudeAgentSdkReplayTranscript,
998 options: {
999 readonly replayGate?: ProviderReplayGate;
1000 readonly queryRunner?: ClaudeQueryRunner;
1001 } = {},
1002) {
1003 const layerServerConfig = Layer.effect(
1004 ServerConfig.ServerConfig,
1005 makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
1006 ).pipe(Layer.provide(NodeServices.layer));
1007 return ProviderAdapterRegistry.layerFromDrivers({
1008 drivers: [ClaudeAdapterV2.ClaudeAdapterV2Driver],
1009 configMap: {
1010 [ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID]: {
1011 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
1012 },
1013 },
1014 }).pipe(
1015 Layer.provide(
1016 Layer.mergeAll(
1017 layerClaudeAgentSdkReplay(transcript, options),
1018 IdAllocator.layer,
1019 NodeServices.layer,
1020 layerServerConfig,
1021 ),
1022 ),
1023 );
1024}
1025
1026function serializeReplayError(error: unknown, scenario?: string): unknown {
1027 return error instanceof Error
1028 ? {
1029 name: error.name,
1030 message:
1031 scenario === undefined
1032 ? error.message
1033 : sanitizeReplayText({ text: error.message, scenario }),
1034 }
1035 : error;
1036}
1037
1038function permissionResultForRecording(input: {
1039 readonly decision: ProviderApprovalDecision;
1040 readonly toolInput: Record<string, unknown>;
1041 readonly toolUseID: string;
1042 readonly suggestions?: Parameters<CanUseTool>[2]["suggestions"];
1043}): PermissionResult {
1044 if (input.decision === "accept" || input.decision === "acceptForSession") {
1045 return {
1046 behavior: "allow",
1047 updatedInput: input.toolInput,
1048 toolUseID: input.toolUseID,
1049 decisionClassification:
1050 input.decision === "acceptForSession" ? "user_permanent" : "user_temporary",
1051 ...(input.decision === "acceptForSession" && input.suggestions !== undefined
1052 ? { updatedPermissions: input.suggestions }
1053 : {}),
1054 };
1055 }
1056 return {
1057 behavior: "deny",
1058 message:
1059 input.decision === "cancel"
1060 ? "User cancelled tool execution."
1061 : "User declined tool execution.",
1062 toolUseID: input.toolUseID,
1063 decisionClassification: "user_reject",
1064 ...(input.decision === "cancel" ? { interrupt: true } : {}),
1065 };
1066}
1067
1068function permissionRequestFrame(input: {
1069 readonly toolName: string;
1070 readonly toolInput: Record<string, unknown>;
1071 readonly callbackOptions: Parameters<CanUseTool>[2];
1072}): ClaudePermissionRequestFrame {
1073 const { callbackOptions } = input;
1074 return {
1075 type: "permission.request",
1076 toolName: input.toolName,
1077 input: input.toolInput,
1078 options: {
1079 ...(callbackOptions.suggestions === undefined
1080 ? {}
1081 : { suggestions: callbackOptions.suggestions }),
1082 ...(callbackOptions.blockedPath === undefined
1083 ? {}
1084 : { blockedPath: callbackOptions.blockedPath }),
1085 ...(callbackOptions.decisionReason === undefined
1086 ? {}
1087 : { decisionReason: callbackOptions.decisionReason }),
1088 ...(callbackOptions.title === undefined ? {} : { title: callbackOptions.title }),
1089 ...(callbackOptions.displayName === undefined
1090 ? {}
1091 : { displayName: callbackOptions.displayName }),
1092 ...(callbackOptions.description === undefined
1093 ? {}
1094 : { description: callbackOptions.description }),
1095 toolUseID: callbackOptions.toolUseID,
1096 ...(callbackOptions.agentID === undefined ? {} : { agentID: callbackOptions.agentID }),
1097 },
1098 };
1099}
1100
1101function sanitizedReplayCwd(scenario: string): string {
1102 return `/tmp/claude-replay-${scenario}`;
1103}
1104
1105function parentDirectory(input: string): string {
1106 const trimmed = input.replace(/\/+$/u, "");
1107 const lastSlash = trimmed.lastIndexOf("/");
1108 if (lastSlash <= 0) {
1109 return "/";
1110 }
1111 return trimmed.slice(0, lastSlash);
1112}
1113
1114function sanitizeReplayText(input: { readonly text: string; readonly scenario: string }): string {
1115 const sanitizedCwd = sanitizedReplayCwd(input.scenario);
1116 const repoRoot = parentDirectory(parentDirectory(process.cwd()));
1117 return [repoRoot, process.cwd()]
1118 .toSorted((left, right) => right.length - left.length)
1119 .reduce((text, localPath) => text.replaceAll(localPath, sanitizedCwd), input.text);
1120}
1121
1122function sanitizeSdkMessageForReplay(input: {
1123 readonly message: SDKMessage;
1124 readonly scenario: string;
1125}): SDKMessage {
1126 const { message } = input;
1127 if (message.type === "system" && message.subtype === "init") {
1128 return {
1129 type: "system",
1130 subtype: "init",
1131 ...(message.agents === undefined ? {} : { agents: [] }),
1132 apiKeySource: message.apiKeySource,
1133 ...(message.betas === undefined ? {} : { betas: message.betas }),
1134 claude_code_version: message.claude_code_version,
1135 cwd: sanitizedReplayCwd(input.scenario),
1136 tools: [],
1137 mcp_servers: [],
1138 model: message.model,
1139 permissionMode: message.permissionMode,
1140 slash_commands: [],
1141 output_style: message.output_style,
1142 skills: [],
1143 plugins: [],
1144 ...(message.fast_mode_state === undefined
1145 ? {}
1146 : { fast_mode_state: message.fast_mode_state }),
1147 uuid: message.uuid,
1148 session_id: message.session_id,
1149 };
1150 }
1151 // Like init's slash_commands, the recording account's commands and skills
1152 // are local configuration, not protocol.
1153 if (message.type === "system" && message.subtype === "commands_changed") {
1154 return { ...message, commands: [] };
1155 }
1156 if (message.type === "rate_limit_event") {
1157 return {
1158 ...message,
1159 rate_limit_info: {
1160 status: message.rate_limit_info.status,
1161 },
1162 };
1163 }
1164 if (message.type === "result" && message.subtype !== "success" && message.errors.length > 0) {
1165 return {
1166 ...message,
1167 errors: message.errors.map((error) =>
1168 sanitizeReplayText({ text: error, scenario: input.scenario }),
1169 ),
1170 };
1171 }
1172 return message;
1173}
1174
1175// Lets a recording wait a bounded time for the next frame without losing it
1176// when the wait times out: the pending read is handed to the next reader.
1177class RecordingMessageReader implements AsyncIterator<SDKMessage> {
1178 private pending: Promise<IteratorResult<SDKMessage>> | undefined;
1179 private readonly iterator: AsyncIterator<SDKMessage>;
1180
1181 constructor(iterator: AsyncIterator<SDKMessage>) {
1182 this.iterator = iterator;
1183 }
1184
1185 next(): Promise<IteratorResult<SDKMessage>> {
1186 const pending = this.pending ?? this.iterator.next();
1187 this.pending = undefined;
1188 return pending;
1189 }
1190
1191 // The next frame if it arrives within `ms`, left unread for next().
1192 async peekWithin(ms: number): Promise<SDKMessage | undefined> {
1193 const pending = this.pending ?? this.iterator.next();
1194 this.pending = pending;
1195 return Promise.race([
1196 // A failed read stays pending for next() to surface.
1197 pending.then(
1198 (result) => (result.done === true ? undefined : result.value),
1199 () => undefined,
1200 ),
1201 Effect.runPromise(Effect.sleep(Duration.millis(ms))).then(() => undefined),
1202 ]);
1203 }
1204}
1205
1206class RecordingPromptQueue implements AsyncIterable<SDKUserMessage> {
1207 private readonly pending: Array<IteratorResult<SDKUserMessage>> = [];
1208 private readonly waiters: Array<(result: IteratorResult<SDKUserMessage>) => void> = [];
1209 private closed = false;
1210
1211 offer(message: SDKUserMessage): void {
1212 if (this.closed) {
1213 throw new Error("Cannot offer a prompt to a closed Claude recording queue.");
1214 }
1215 this.push({ done: false, value: message });
1216 }
1217
1218 close(): void {
1219 if (this.closed) {
1220 return;
1221 }
1222 this.closed = true;
1223 this.push({ done: true, value: undefined });
1224 }
1225
1226 async *[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
1227 while (true) {
1228 const next = await this.take();
1229 if (next.done === true) {
1230 return;
1231 }
1232 yield next.value;
1233 }
1234 }
1235
1236 private push(result: IteratorResult<SDKUserMessage>): void {
1237 const waiter = this.waiters.shift();
1238 if (waiter === undefined) {
1239 this.pending.push(result);
1240 return;
1241 }
1242 waiter(result);
1243 }
1244
1245 private take(): Promise<IteratorResult<SDKUserMessage>> {
1246 const next = this.pending.shift();
1247 if (next !== undefined) {
1248 return Promise.resolve(next);
1249 }
1250 return new Promise((resolve) => {
1251 this.waiters.push(resolve);
1252 });
1253 }
1254}
1255
1256async function recordMessagesUntilTurnResult(input: {
1257 readonly iterator: AsyncIterator<SDKMessage>;
1258 readonly entries: Array<ProviderReplayEntry>;
1259 readonly scenario: string;
1260}): Promise<boolean> {
1261 while (true) {
1262 const next = await input.iterator.next();
1263 if (next.done === true) {
1264 return false;
1265 }
1266 const replayMessage = sanitizeSdkMessageForReplay({
1267 message: next.value,
1268 scenario: input.scenario,
1269 });
1270 input.entries.push({
1271 type: "emit_inbound",
1272 label: replayMessage.type,
1273 frame: replayMessage,
1274 });
1275 if (replayMessage.type === "result") {
1276 return true;
1277 }
1278 }
1279}
1280
1281async function recordMessagesUntilTurnResultAndFinalize(input: {
1282 readonly iterator: AsyncIterator<SDKMessage>;
1283 readonly entries: Array<ProviderReplayEntry>;
1284 readonly scenario: string;
1285 readonly finalize: () => void;
1286}): Promise<boolean> {
1287 try {
1288 return await recordMessagesUntilTurnResult(input);
1289 } finally {
1290 input.finalize();
1291 }
1292}
1293
1294async function recordMessagesUntilTurnResultWithCursor(input: {
1295 readonly iterator: AsyncIterator<SDKMessage>;
1296 readonly entries: Array<ProviderReplayEntry>;
1297 readonly scenario: string;
1298}): Promise<{
1299 readonly completed: boolean;
1300 readonly assistantMessageUuid: SDKAssistantMessage["uuid"] | null;
1301}> {
1302 let assistantMessageUuid: SDKAssistantMessage["uuid"] | null = null;
1303 while (true) {
1304 const next = await input.iterator.next();
1305 if (next.done === true) {
1306 return { completed: false, assistantMessageUuid };
1307 }
1308 const replayMessage = sanitizeSdkMessageForReplay({
1309 message: next.value,
1310 scenario: input.scenario,
1311 });
1312 input.entries.push({
1313 type: "emit_inbound",
1314 label: replayMessage.type,
1315 frame: replayMessage,
1316 });
1317 if (replayMessage.type === "assistant") {
1318 assistantMessageUuid = replayMessage.uuid;
1319 }
1320 if (replayMessage.type === "result") {
1321 return { completed: true, assistantMessageUuid };
1322 }
1323 }
1324}
1325
1326function requireAssistantCursor(input: {
1327 readonly scenario: string;
1328 readonly promptIndex: number;
1329 readonly cursor: SDKAssistantMessage["uuid"] | null;
1330}): SDKAssistantMessage["uuid"] {
1331 if (input.cursor !== null) {
1332 return input.cursor;
1333 }
1334 throw new Error(
1335 `Claude replay scenario ${input.scenario} prompt ${input.promptIndex} completed without an SDKAssistantMessage.uuid cursor.`,
1336 );
1337}
1338
1339async function recordMessagesUntilTurnResults(input: {
1340 readonly iterator: AsyncIterator<SDKMessage>;
1341 readonly entries: Array<ProviderReplayEntry>;
1342 readonly scenario: string;
1343 readonly resultCount: number;
1344}): Promise<boolean> {
1345 let seenResults = 0;
1346 while (true) {
1347 const next = await input.iterator.next();
1348 if (next.done === true) {
1349 return false;
1350 }
1351 const replayMessage = sanitizeSdkMessageForReplay({
1352 message: next.value,
1353 scenario: input.scenario,
1354 });
1355 input.entries.push({
1356 type: "emit_inbound",
1357 label: replayMessage.type,
1358 frame: replayMessage,
1359 });
1360 if (replayMessage.type === "result") {
1361 seenResults += 1;
1362 if (seenResults >= input.resultCount) {
1363 return true;
1364 }
1365 }
1366 }
1367}
1368
1369async function recordMessagesUntilIteratorDone(input: {
1370 readonly iterator: AsyncIterator<SDKMessage>;
1371 readonly entries: Array<ProviderReplayEntry>;
1372 readonly scenario: string;
1373}): Promise<void> {
1374 while (true) {
1375 const next = await input.iterator.next();
1376 if (next.done === true) {
1377 return;
1378 }
1379 const replayMessage = sanitizeSdkMessageForReplay({
1380 message: next.value,
1381 scenario: input.scenario,
1382 });
1383 input.entries.push({
1384 type: "emit_inbound",
1385 label: replayMessage.type,
1386 frame: replayMessage,
1387 });
1388 }
1389}
1390
1391// A queued wake turn starts right after the turn before it settles.
1392const CLAUDE_RECORDING_WAKE_QUIET_MS = 5_000;
1393
1394function isSystemInitFrame(message: SDKMessage | undefined): boolean {
1395 return message?.type === "system" && message.subtype === "init";
1396}
1397
1398function isTaskNotificationOriginResultFrame(frame: unknown): boolean {
1399 if (typeof frame !== "object" || frame === null || Reflect.get(frame, "type") !== "result") {
1400 return false;
1401 }
1402 const origin: unknown = Reflect.get(frame, "origin");
1403 return (
1404 typeof origin === "object" &&
1405 origin !== null &&
1406 Reflect.get(origin, "kind") === "task-notification"
1407 );
1408}
1409
1410function sdkMessageHasRootToolUse(message: SDKMessage): boolean {
1411 return (
1412 message.type === "assistant" &&
1413 message.parent_tool_use_id === null &&
1414 message.message.content.some((part) => part.type === "tool_use")
1415 );
1416}
1417
1418async function recordMessagesUntilToolUse(input: {
1419 readonly iterator: AsyncIterator<SDKMessage>;
1420 readonly entries: Array<ProviderReplayEntry>;
1421 readonly scenario: string;
1422 // Returns after the assistant frame carrying this many root tool uses.
1423 readonly toolUseCount: number;
1424}): Promise<void> {
1425 let toolUses = 0;
1426 while (true) {
1427 const next = await input.iterator.next();
1428 if (next.done === true) {
1429 throw new Error(`Claude query ended before ${input.scenario} started a tool use.`);
1430 }
1431 const replayMessage = sanitizeSdkMessageForReplay({
1432 message: next.value,
1433 scenario: input.scenario,
1434 });
1435 input.entries.push({
1436 type: "emit_inbound",
1437 label: replayMessage.type,
1438 frame: replayMessage,
1439 });
1440 if (replayMessage.type === "result") {
1441 throw new Error(`Claude query completed before ${input.scenario} started a tool use.`);
1442 }
1443 if (sdkMessageHasRootToolUse(replayMessage)) {
1444 toolUses += 1;
1445 if (toolUses >= input.toolUseCount) {
1446 return;
1447 }
1448 }
1449 }
1450}
1451
1452// The SDK package may not ship a Claude Code executable for this platform, so
1453// recordings prefer the installed `claude` binary when it resolves on PATH to
1454// something the SDK can spawn directly, and otherwise leave the SDK's own
1455// executable discovery in place. A Windows launcher shim (`claude.cmd` and
1456// friends) is not directly spawnable, so it only counts when
1457// resolveClaudeSdkExecutablePath can follow it to a real package entry.
1458const resolveClaudeRecordingExecutablePath = Effect.fn("resolveClaudeRecordingExecutablePath")(
1459 function* (environment: NodeJS.ProcessEnv) {
1460 const resolveExecutable = yield* SpawnExecutableResolution;
1461 const platform = yield* HostProcess.Platform;
1462 const resolved = resolveExecutable("claude", platform, environment);
1463 if (resolved === undefined) {
1464 return undefined;
1465 }
1466 const executablePath = yield* resolveClaudeSdkExecutablePath(resolved, environment);
1467 if (platform === "win32" && isWindowsClaudeLauncherShimPath(executablePath)) {
1468 return undefined;
1469 }
1470 return executablePath;
1471 },
1472);
1473
1474async function openRecordingQuery(input: Parameters<typeof query>[0]) {
1475 const executablePath = await Effect.runPromise(resolveClaudeRecordingExecutablePath(process.env));
1476 return query({
1477 ...input,
1478 options: {
1479 ...input.options,
1480 ...(executablePath === undefined ? {} : { pathToClaudeCodeExecutable: executablePath }),
1481 },
1482 });
1483}
1484
1485async function recordClaudeStreamingQuery(input: {
1486 readonly scenario: string;
1487 readonly prompts: ReadonlyArray<string>;
1488 readonly modelSelection: ModelSelection;
1489 readonly cwd: string;
1490 readonly sessionId: string;
1491 readonly entries: Array<ProviderReplayEntry>;
1492 readonly enableTools?: boolean;
1493 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
1494 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
1495 readonly allowedTools?: ReadonlyArray<string>;
1496 readonly disallowedTools?: ReadonlyArray<string>;
1497 readonly allowDangerouslySkipPermissions?: boolean;
1498 readonly enablePermissionCallback?: boolean;
1499 readonly permissionDecision?: ProviderApprovalDecision;
1500 // Per prompt, how many turns Claude starts on its own for background work
1501 // (task-notification-origin results) to wait for before the next prompt.
1502 // Setting it also records any further wake turn that starts within a short
1503 // quiet window, so the next prompt is not offered while one is queued.
1504 readonly backgroundWakeCounts?: ReadonlyArray<number>;
1505 // Skip that quiet window: offer the next prompt while a wake may still be
1506 // queued in the CLI, which then runs the wake turn first.
1507 readonly offerNextPromptImmediately?: boolean;
1508}): Promise<void> {
1509 const promptQueue = new RecordingPromptQueue();
1510 const canUseTool: CanUseTool | undefined =
1511 input.enablePermissionCallback === true
1512 ? async (toolName, toolInput, callbackOptions) => {
1513 const requestFrame = permissionRequestFrame({
1514 toolName,
1515 toolInput,
1516 callbackOptions,
1517 });
1518 input.entries.push({
1519 type: "emit_inbound",
1520 label: `permission.request:${toolName}`,
1521 frame: requestFrame,
1522 });
1523 const result = permissionResultForRecording({
1524 decision: input.permissionDecision ?? "accept",
1525 toolInput,
1526 toolUseID: callbackOptions.toolUseID,
1527 ...(callbackOptions.suggestions === undefined
1528 ? {}
1529 : { suggestions: callbackOptions.suggestions }),
1530 });
1531 input.entries.push({
1532 type: "expect_outbound",
1533 label: `permission.response:${toolName}`,
1534 frame: makeClaudePermissionResponseFrame(result),
1535 });
1536 return result;
1537 }
1538 : undefined;
1539 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
1540 modelSelection: input.modelSelection,
1541 nativeThreadId: input.sessionId,
1542 resume: false,
1543 cwd: input.cwd,
1544 ...(input.enableTools === true
1545 ? {
1546 tools: input.tools ?? { type: "preset", preset: "claude_code" },
1547 permissionMode: input.permissionMode ?? "default",
1548 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
1549 ...(input.disallowedTools === undefined
1550 ? {}
1551 : { disallowedTools: input.disallowedTools }),
1552 ...(input.allowDangerouslySkipPermissions === true
1553 ? { allowDangerouslySkipPermissions: true }
1554 : {}),
1555 ...(canUseTool === undefined ? {} : { canUseTool }),
1556 }
1557 : {}),
1558 });
1559 input.entries.push({
1560 type: "expect_outbound",
1561 label: "query.open",
1562 frame: makeClaudeQueryOpenFrame({ options }),
1563 });
1564 const queryRuntime = await openRecordingQuery({
1565 prompt: promptQueue,
1566 options,
1567 });
1568 const iterator = new RecordingMessageReader(queryRuntime[Symbol.asyncIterator]());
1569 let wakeNumber = 0;
1570 // Records one turn and labels it when Claude started it for background work.
1571 const recordTurn = async (promptNumber: number): Promise<"prompt" | "wake"> => {
1572 const completed = await recordMessagesUntilTurnResult({
1573 iterator,
1574 entries: input.entries,
1575 scenario: input.scenario,
1576 });
1577 if (!completed) {
1578 throw new Error(
1579 `Claude streaming query ended before prompt ${promptNumber} and its background wakes completed.`,
1580 );
1581 }
1582 const resultEntry = input.entries.at(-1);
1583 const resultFrame = resultEntry?.type === "emit_inbound" ? resultEntry.frame : undefined;
1584 if (!isTaskNotificationOriginResultFrame(resultFrame)) {
1585 return "prompt";
1586 }
1587 wakeNumber += 1;
1588 // A distinct label lets a replay gate hold the wake result until the
1589 // continuation run that ingests it has started.
1590 input.entries[input.entries.length - 1] = {
1591 type: "emit_inbound",
1592 label: claudeBackgroundWakeResultLabel(wakeNumber),
1593 frame: resultFrame,
1594 };
1595 return "wake";
1596 };
1597 try {
1598 for (const [index, prompt] of input.prompts.entries()) {
1599 // Like the adapter, give each prompt a fresh uuid Claude echoes on its turn.
1600 const message = ClaudeAdapterV2.makeClaudeUserMessage({
1601 text: prompt,
1602 uuid: await Effect.runPromise(
1603 Crypto.Crypto.pipe(
1604 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
1605 Effect.filterOrFail(ClaudeAdapterV2.isClaudePromptUuid),
1606 Effect.provide(NodeServices.layer),
1607 ),
1608 ),
1609 });
1610 input.entries.push({
1611 type: "expect_outbound",
1612 label: `prompt.offer:${index + 1}`,
1613 frame: makeClaudePromptOfferFrame(message),
1614 });
1615 promptQueue.offer(message);
1616 // A task notification that lands during a turn queues a wake turn the
1617 // CLI can run before this prompt's turn, so results are told apart by
1618 // origin rather than by arrival order.
1619 let promptSettled = false;
1620 let wakes = 0;
1621 const expectedWakes = input.backgroundWakeCounts?.[index] ?? 0;
1622 while (!promptSettled || wakes < expectedWakes) {
1623 if ((await recordTurn(index + 1)) === "prompt") {
1624 promptSettled = true;
1625 } else {
1626 wakes += 1;
1627 }
1628 }
1629 if (input.backgroundWakeCounts !== undefined && input.offerNextPromptImmediately !== true) {
1630 // Every turn opens with system:init; frames from still-running
1631 // subagents are left for the next prompt's recording.
1632 while (isSystemInitFrame(await iterator.peekWithin(CLAUDE_RECORDING_WAKE_QUIET_MS))) {
1633 await recordTurn(index + 1);
1634 }
1635 }
1636 }
1637 promptQueue.close();
1638 queryRuntime.close();
1639 input.entries.push({
1640 type: "runtime_exit",
1641 status: "success",
1642 });
1643 } catch (error) {
1644 promptQueue.close();
1645 queryRuntime.close();
1646 input.entries.push({
1647 type: "runtime_exit",
1648 status: "error",
1649 error: serializeReplayError(error, input.scenario),
1650 });
1651 throw error;
1652 }
1653}
1654
1655async function recordClaudeActiveSteeringQuery(input: {
1656 readonly scenario: string;
1657 readonly prompts: ReadonlyArray<string>;
1658 readonly modelSelection: ModelSelection;
1659 readonly cwd: string;
1660 readonly sessionId: string;
1661 readonly entries: Array<ProviderReplayEntry>;
1662 readonly enableTools?: boolean;
1663 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
1664 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
1665 readonly allowedTools?: ReadonlyArray<string>;
1666 readonly disallowedTools?: ReadonlyArray<string>;
1667 readonly allowDangerouslySkipPermissions?: boolean;
1668 readonly enablePermissionCallback?: boolean;
1669 readonly permissionDecision?: ProviderApprovalDecision;
1670}): Promise<void> {
1671 if (input.prompts.length < 2) {
1672 throw new Error("Claude active steering replay recording requires at least two prompts.");
1673 }
1674
1675 const promptQueue = new RecordingPromptQueue();
1676 const offeredPrompts = new Set<number>();
1677 const offerPrompt = (index: number, priority?: SDKUserMessage["priority"]) => {
1678 const message = ClaudeAdapterV2.makeClaudeUserMessage({
1679 text: input.prompts[index]!,
1680 ...(priority === undefined ? {} : { priority }),
1681 });
1682 input.entries.push({
1683 type: "expect_outbound",
1684 label: `prompt.offer:${index + 1}`,
1685 frame: makeClaudePromptOfferFrame(message),
1686 });
1687 promptQueue.offer(message);
1688 offeredPrompts.add(index);
1689 };
1690 const offerSteeringPrompts = () => {
1691 for (let index = 1; index < input.prompts.length; index += 1) {
1692 if (!offeredPrompts.has(index)) {
1693 offerPrompt(index, "now");
1694 }
1695 }
1696 };
1697
1698 const canUseTool: CanUseTool | undefined =
1699 input.enablePermissionCallback === true
1700 ? async (toolName, toolInput, callbackOptions) => {
1701 const requestFrame = permissionRequestFrame({
1702 toolName,
1703 toolInput,
1704 callbackOptions,
1705 });
1706 input.entries.push({
1707 type: "emit_inbound",
1708 label: `permission.request:${toolName}`,
1709 frame: requestFrame,
1710 });
1711 const result = permissionResultForRecording({
1712 decision: input.permissionDecision ?? "accept",
1713 toolInput,
1714 toolUseID: callbackOptions.toolUseID,
1715 ...(callbackOptions.suggestions === undefined
1716 ? {}
1717 : { suggestions: callbackOptions.suggestions }),
1718 });
1719 input.entries.push({
1720 type: "expect_outbound",
1721 label: `permission.response:${toolName}`,
1722 frame: makeClaudePermissionResponseFrame(result),
1723 });
1724 return result;
1725 }
1726 : undefined;
1727 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
1728 modelSelection: input.modelSelection,
1729 nativeThreadId: input.sessionId,
1730 resume: false,
1731 cwd: input.cwd,
1732 ...(input.enableTools === true
1733 ? {
1734 tools: input.tools ?? { type: "preset", preset: "claude_code" },
1735 permissionMode: input.permissionMode ?? "default",
1736 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
1737 ...(input.disallowedTools === undefined
1738 ? {}
1739 : { disallowedTools: input.disallowedTools }),
1740 ...(input.allowDangerouslySkipPermissions === true
1741 ? { allowDangerouslySkipPermissions: true }
1742 : {}),
1743 ...(canUseTool === undefined ? {} : { canUseTool }),
1744 }
1745 : {}),
1746 });
1747 input.entries.push({
1748 type: "expect_outbound",
1749 label: "query.open",
1750 frame: makeClaudeQueryOpenFrame({ options }),
1751 });
1752 const queryRuntime = await openRecordingQuery({
1753 prompt: promptQueue,
1754 options,
1755 });
1756 const iterator = queryRuntime[Symbol.asyncIterator]();
1757 try {
1758 offerPrompt(0);
1759 offerSteeringPrompts();
1760 const completed = await recordMessagesUntilTurnResults({
1761 iterator,
1762 entries: input.entries,
1763 scenario: input.scenario,
1764 resultCount: input.prompts.length,
1765 });
1766 if (!completed) {
1767 throw new Error("Claude active steering query ended before the turn completed.");
1768 }
1769 if (offeredPrompts.size < input.prompts.length) {
1770 throw new Error("Claude active steering prompts were not all offered before completion.");
1771 }
1772 promptQueue.close();
1773 queryRuntime.close();
1774 input.entries.push({
1775 type: "runtime_exit",
1776 status: "success",
1777 });
1778 } catch (error) {
1779 promptQueue.close();
1780 queryRuntime.close();
1781 input.entries.push({
1782 type: "runtime_exit",
1783 status: "error",
1784 error: serializeReplayError(error, input.scenario),
1785 });
1786 throw error;
1787 }
1788}
1789
1790async function recordClaudeRestartingQueries(input: {
1791 readonly scenario: string;
1792 readonly prompts: ReadonlyArray<string>;
1793 readonly modelSelection: ModelSelection;
1794 readonly cwd: string;
1795 readonly sessionId: string;
1796 readonly entries: Array<ProviderReplayEntry>;
1797 readonly enableTools?: boolean;
1798 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
1799 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
1800 readonly allowedTools?: ReadonlyArray<string>;
1801 readonly disallowedTools?: ReadonlyArray<string>;
1802 readonly allowDangerouslySkipPermissions?: boolean;
1803}): Promise<void> {
1804 for (const [index, prompt] of input.prompts.entries()) {
1805 const promptQueue = new RecordingPromptQueue();
1806 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
1807 modelSelection: input.modelSelection,
1808 nativeThreadId: input.sessionId,
1809 resume: index > 0,
1810 cwd: input.cwd,
1811 ...(input.enableTools === true
1812 ? {
1813 tools: input.tools ?? { type: "preset", preset: "claude_code" },
1814 permissionMode: input.permissionMode ?? "default",
1815 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
1816 ...(input.disallowedTools === undefined
1817 ? {}
1818 : { disallowedTools: input.disallowedTools }),
1819 ...(input.allowDangerouslySkipPermissions === true
1820 ? { allowDangerouslySkipPermissions: true }
1821 : {}),
1822 }
1823 : {}),
1824 });
1825
1826 input.entries.push({
1827 type: "expect_outbound",
1828 label: `query.open:${index + 1}`,
1829 frame: makeClaudeQueryOpenFrame({ options }),
1830 });
1831 const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt });
1832 input.entries.push({
1833 type: "expect_outbound",
1834 label: `prompt.offer:${index + 1}`,
1835 frame: makeClaudePromptOfferFrame(message),
1836 });
1837
1838 try {
1839 const queryRuntime = await openRecordingQuery({
1840 prompt: promptQueue,
1841 options,
1842 });
1843 promptQueue.offer(message);
1844 promptQueue.close();
1845 const iterator = queryRuntime[Symbol.asyncIterator]();
1846 for (;;) {
1847 const next = await iterator.next();
1848 if (next.done === true) {
1849 break;
1850 }
1851 const replayMessage = sanitizeSdkMessageForReplay({
1852 message: next.value,
1853 scenario: input.scenario,
1854 });
1855 input.entries.push({
1856 type: "emit_inbound",
1857 label: replayMessage.type,
1858 frame: replayMessage,
1859 });
1860 }
1861 input.entries.push({
1862 type: "runtime_exit",
1863 status: "success",
1864 });
1865 } catch (error) {
1866 promptQueue.close();
1867 input.entries.push({
1868 type: "runtime_exit",
1869 status: "error",
1870 error: serializeReplayError(error, input.scenario),
1871 });
1872 throw error;
1873 }
1874 }
1875}
1876
1877async function recordClaudeResumeAtCursorQuery(input: {
1878 readonly scenario: string;
1879 readonly prompts: ReadonlyArray<string>;
1880 readonly modelSelection: ModelSelection;
1881 readonly cwd: string;
1882 readonly sessionId: string;
1883 readonly entries: Array<ProviderReplayEntry>;
1884 readonly metadata: Record<string, unknown>;
1885 readonly enableTools?: boolean;
1886 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
1887 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
1888 readonly allowedTools?: ReadonlyArray<string>;
1889 readonly disallowedTools?: ReadonlyArray<string>;
1890 readonly allowDangerouslySkipPermissions?: boolean;
1891}): Promise<void> {
1892 if (input.prompts.length !== 3) {
1893 throw new Error(
1894 `Claude resume-at-cursor replay scenario ${input.scenario} requires exactly three prompts.`,
1895 );
1896 }
1897
1898 const sourcePromptQueue = new RecordingPromptQueue();
1899 const sourceOptions = ClaudeAdapterV2.makeClaudeQueryOptions({
1900 modelSelection: input.modelSelection,
1901 nativeThreadId: input.sessionId,
1902 resume: false,
1903 cwd: input.cwd,
1904 ...(input.enableTools === true
1905 ? {
1906 tools: input.tools ?? { type: "preset", preset: "claude_code" },
1907 permissionMode: input.permissionMode ?? "default",
1908 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
1909 ...(input.disallowedTools === undefined
1910 ? {}
1911 : { disallowedTools: input.disallowedTools }),
1912 ...(input.allowDangerouslySkipPermissions === true
1913 ? { allowDangerouslySkipPermissions: true }
1914 : {}),
1915 }
1916 : {}),
1917 });
1918
1919 input.entries.push({
1920 type: "expect_outbound",
1921 label: "query.open:source",
1922 frame: makeClaudeQueryOpenFrame({ options: sourceOptions }),
1923 });
1924 const sourceRuntime = await openRecordingQuery({
1925 prompt: sourcePromptQueue,
1926 options: sourceOptions,
1927 });
1928 const sourceIterator = sourceRuntime[Symbol.asyncIterator]();
1929
1930 try {
1931 const sourceCursors: Array<SDKAssistantMessage["uuid"]> = [];
1932 for (const [index, prompt] of input.prompts.slice(0, 2).entries()) {
1933 const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt });
1934 input.entries.push({
1935 type: "expect_outbound",
1936 label: `prompt.offer:${index + 1}`,
1937 frame: makeClaudePromptOfferFrame(message),
1938 });
1939 sourcePromptQueue.offer(message);
1940 const result = await recordMessagesUntilTurnResultWithCursor({
1941 iterator: sourceIterator,
1942 entries: input.entries,
1943 scenario: input.scenario,
1944 });
1945 if (!result.completed) {
1946 throw new Error(`Claude source query ended before prompt ${index + 1} completed.`);
1947 }
1948 sourceCursors.push(
1949 requireAssistantCursor({
1950 scenario: input.scenario,
1951 promptIndex: index + 1,
1952 cursor: result.assistantMessageUuid,
1953 }),
1954 );
1955 }
1956 sourcePromptQueue.close();
1957 sourceRuntime.close();
1958 input.entries.push({
1959 type: "runtime_exit",
1960 status: "success",
1961 });
1962
1963 const resumeSessionAt = sourceCursors[0]!;
1964 input.metadata.resumeSessionAt = resumeSessionAt;
1965 input.metadata.sourceAssistantMessageUuids = sourceCursors;
1966
1967 const resumedPromptQueue = new RecordingPromptQueue();
1968 const resumedOptions = {
1969 ...ClaudeAdapterV2.makeClaudeQueryOptions({
1970 modelSelection: input.modelSelection,
1971 nativeThreadId: input.sessionId,
1972 resume: true,
1973 cwd: input.cwd,
1974 ...(input.enableTools === true
1975 ? {
1976 tools: input.tools ?? { type: "preset", preset: "claude_code" },
1977 permissionMode: input.permissionMode ?? "default",
1978 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
1979 ...(input.disallowedTools === undefined
1980 ? {}
1981 : { disallowedTools: input.disallowedTools }),
1982 ...(input.allowDangerouslySkipPermissions === true
1983 ? { allowDangerouslySkipPermissions: true }
1984 : {}),
1985 }
1986 : {}),
1987 }),
1988 resumeSessionAt,
1989 } satisfies ClaudeAdapterV2.ClaudeAgentSdkQueryOptions;
1990 input.entries.push({
1991 type: "expect_outbound",
1992 label: "query.open:resume_at_cursor",
1993 frame: makeClaudeQueryOpenFrame({ options: resumedOptions }),
1994 });
1995 const resumedMessage = ClaudeAdapterV2.makeClaudeUserMessage({ text: input.prompts[2]! });
1996 input.entries.push({
1997 type: "expect_outbound",
1998 label: "prompt.offer:3",
1999 frame: makeClaudePromptOfferFrame(resumedMessage),
2000 });
2001
2002 const resumedRuntime = await openRecordingQuery({
2003 prompt: resumedPromptQueue,
2004 options: resumedOptions,
2005 });
2006 resumedPromptQueue.offer(resumedMessage);
2007 resumedPromptQueue.close();
2008 const resumedIterator = resumedRuntime[Symbol.asyncIterator]();
2009 const resumed = await recordMessagesUntilTurnResultAndFinalize({
2010 iterator: resumedIterator,
2011 entries: input.entries,
2012 scenario: input.scenario,
2013 finalize: () => {
2014 resumedPromptQueue.close();
2015 resumedRuntime.close();
2016 },
2017 });
2018 if (!resumed) {
2019 throw new Error("Claude resumed query ended before prompt 3 completed.");
2020 }
2021 input.entries.push({
2022 type: "runtime_exit",
2023 status: "success",
2024 });
2025 } catch (error) {
2026 sourcePromptQueue.close();
2027 sourceRuntime.close();
2028 input.entries.push({
2029 type: "runtime_exit",
2030 status: "error",
2031 error: serializeReplayError(error, input.scenario),
2032 });
2033 throw error;
2034 }
2035}
2036
2037async function recordClaudeForkSessionQuery(input: {
2038 readonly scenario: string;
2039 readonly prompts: ReadonlyArray<string>;
2040 readonly modelSelection: ModelSelection;
2041 readonly cwd: string;
2042 readonly sessionId: string;
2043 readonly entries: Array<ProviderReplayEntry>;
2044 readonly metadata: Record<string, unknown>;
2045 readonly forkFromPromptIndex?: 1 | 2;
2046 readonly sourcePromptCount?: number;
2047 readonly forkPromptGroups?: ReadonlyArray<ReadonlyArray<string>>;
2048 readonly sourceContinuationPromptCount?: number;
2049 readonly enableTools?: boolean;
2050 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
2051 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
2052 readonly allowedTools?: ReadonlyArray<string>;
2053 readonly disallowedTools?: ReadonlyArray<string>;
2054 readonly allowDangerouslySkipPermissions?: boolean;
2055}): Promise<void> {
2056 if (input.prompts.length < 2) {
2057 throw new Error(
2058 `Claude fork-session replay scenario ${input.scenario} requires at least two prompts.`,
2059 );
2060 }
2061 const sourceContinuationPromptCount = input.sourceContinuationPromptCount ?? 0;
2062 const forkPromptEnd = input.prompts.length - sourceContinuationPromptCount;
2063 const sourcePromptCount = input.sourcePromptCount ?? forkPromptEnd - 1;
2064 if (
2065 sourceContinuationPromptCount < 0 ||
2066 sourceContinuationPromptCount >= input.prompts.length ||
2067 sourcePromptCount < 1 ||
2068 sourcePromptCount >= forkPromptEnd
2069 ) {
2070 throw new Error(
2071 `Claude fork-session replay scenario ${input.scenario} requires at least one source prompt and one fork prompt.`,
2072 );
2073 }
2074 const forkFromPromptIndex = input.forkFromPromptIndex ?? sourcePromptCount;
2075 if (forkFromPromptIndex > sourcePromptCount) {
2076 throw new Error(
2077 `Claude fork-session replay scenario ${input.scenario} cannot fork from prompt ${forkFromPromptIndex} after recording ${sourcePromptCount} source prompts.`,
2078 );
2079 }
2080
2081 const sourcePromptQueue = new RecordingPromptQueue();
2082 const sourceOptions = ClaudeAdapterV2.makeClaudeQueryOptions({
2083 modelSelection: input.modelSelection,
2084 nativeThreadId: input.sessionId,
2085 resume: false,
2086 cwd: input.cwd,
2087 ...(input.enableTools === true
2088 ? {
2089 tools: input.tools ?? { type: "preset", preset: "claude_code" },
2090 permissionMode: input.permissionMode ?? "default",
2091 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2092 ...(input.disallowedTools === undefined
2093 ? {}
2094 : { disallowedTools: input.disallowedTools }),
2095 ...(input.allowDangerouslySkipPermissions === true
2096 ? { allowDangerouslySkipPermissions: true }
2097 : {}),
2098 }
2099 : {}),
2100 });
2101 input.entries.push({
2102 type: "expect_outbound",
2103 label: "query.open:source",
2104 frame: makeClaudeQueryOpenFrame({ options: sourceOptions }),
2105 });
2106 const sourceRuntime = await openRecordingQuery({
2107 prompt: sourcePromptQueue,
2108 options: sourceOptions,
2109 });
2110 const sourceIterator = sourceRuntime[Symbol.asyncIterator]();
2111
2112 try {
2113 const sourceCursors: Array<SDKAssistantMessage["uuid"]> = [];
2114 for (const [index, prompt] of input.prompts.slice(0, sourcePromptCount).entries()) {
2115 const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt });
2116 input.entries.push({
2117 type: "expect_outbound",
2118 label: `prompt.offer:${index + 1}`,
2119 frame: makeClaudePromptOfferFrame(message),
2120 });
2121 sourcePromptQueue.offer(message);
2122 const result = await recordMessagesUntilTurnResultWithCursor({
2123 iterator: sourceIterator,
2124 entries: input.entries,
2125 scenario: input.scenario,
2126 });
2127 if (!result.completed) {
2128 throw new Error(`Claude source query ended before prompt ${index + 1} completed.`);
2129 }
2130 sourceCursors.push(
2131 requireAssistantCursor({
2132 scenario: input.scenario,
2133 promptIndex: index + 1,
2134 cursor: result.assistantMessageUuid,
2135 }),
2136 );
2137 }
2138 sourcePromptQueue.close();
2139 sourceRuntime.close();
2140 input.entries.push({
2141 type: "runtime_exit",
2142 status: "success",
2143 });
2144
2145 const upToMessageId = sourceCursors[forkFromPromptIndex - 1]!;
2146 const forkPrompts = input.prompts.slice(sourcePromptCount, forkPromptEnd);
2147 const forkPromptGroups = input.forkPromptGroups ?? [forkPrompts];
2148 if (
2149 forkPromptGroups.length === 0 ||
2150 forkPromptGroups.some((group) => group.length === 0) ||
2151 forkPromptGroups.flat().join("\n") !== forkPrompts.join("\n")
2152 ) {
2153 throw new Error(
2154 `Claude fork-session replay scenario ${input.scenario} has invalid fork prompt groups.`,
2155 );
2156 }
2157 input.metadata.sourceAssistantMessageUuids = sourceCursors;
2158 input.metadata.forkUpToMessageId = upToMessageId;
2159 const forkedNativeSessionIds: Array<string> = [];
2160 let promptOrdinal = sourcePromptCount;
2161 for (const [groupIndex, forkPrompts] of forkPromptGroups.entries()) {
2162 const labelSuffix = forkPromptGroups.length === 1 ? "" : `:${groupIndex + 1}`;
2163 input.entries.push({
2164 type: "expect_outbound",
2165 label: `session.fork${labelSuffix}`,
2166 frame: {
2167 type: "session.fork",
2168 sessionId: input.sessionId,
2169 options: {
2170 dir: sanitizedReplayCwd(input.scenario),
2171 upToMessageId,
2172 },
2173 },
2174 });
2175 const forked = await forkSession(input.sessionId, {
2176 dir: input.cwd,
2177 upToMessageId,
2178 });
2179 forkedNativeSessionIds.push(forked.sessionId);
2180 input.entries.push({
2181 type: "emit_inbound",
2182 label: `session.forked${labelSuffix}`,
2183 frame: {
2184 type: "session.forked",
2185 sessionId: forked.sessionId,
2186 },
2187 });
2188
2189 const targetPromptQueue = new RecordingPromptQueue();
2190 const targetOptions = ClaudeAdapterV2.makeClaudeQueryOptions({
2191 modelSelection: input.modelSelection,
2192 nativeThreadId: forked.sessionId,
2193 resume: true,
2194 cwd: input.cwd,
2195 ...(input.enableTools === true
2196 ? {
2197 tools: input.tools ?? { type: "preset", preset: "claude_code" },
2198 permissionMode: input.permissionMode ?? "default",
2199 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2200 ...(input.disallowedTools === undefined
2201 ? {}
2202 : { disallowedTools: input.disallowedTools }),
2203 ...(input.allowDangerouslySkipPermissions === true
2204 ? { allowDangerouslySkipPermissions: true }
2205 : {}),
2206 }
2207 : {}),
2208 });
2209 input.entries.push({
2210 type: "expect_outbound",
2211 label: `query.open:fork${labelSuffix}`,
2212 frame: makeClaudeQueryOpenFrame({ options: targetOptions }),
2213 });
2214 const targetRuntime = await openRecordingQuery({
2215 prompt: targetPromptQueue,
2216 options: targetOptions,
2217 });
2218 const targetIterator = targetRuntime[Symbol.asyncIterator]();
2219 for (const prompt of forkPrompts) {
2220 promptOrdinal += 1;
2221 const targetMessage = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt });
2222 input.entries.push({
2223 type: "expect_outbound",
2224 label: `prompt.offer:${promptOrdinal}`,
2225 frame: makeClaudePromptOfferFrame(targetMessage),
2226 });
2227 targetPromptQueue.offer(targetMessage);
2228 const completed = await recordMessagesUntilTurnResult({
2229 iterator: targetIterator,
2230 entries: input.entries,
2231 scenario: input.scenario,
2232 });
2233 if (!completed) {
2234 throw new Error(`Claude fork query ended before prompt ${promptOrdinal} completed.`);
2235 }
2236 }
2237 targetPromptQueue.close();
2238 targetRuntime.close();
2239 input.entries.push({
2240 type: "runtime_exit",
2241 status: "success",
2242 });
2243 }
2244 input.metadata.forkedNativeSessionId = forkedNativeSessionIds[0];
2245 input.metadata.forkedNativeSessionIds = forkedNativeSessionIds;
2246 if (sourceContinuationPromptCount > 0) {
2247 const continuationPromptQueue = new RecordingPromptQueue();
2248 const continuationOptions = ClaudeAdapterV2.makeClaudeQueryOptions({
2249 modelSelection: input.modelSelection,
2250 nativeThreadId: input.sessionId,
2251 resume: true,
2252 cwd: input.cwd,
2253 ...(input.enableTools === true
2254 ? {
2255 tools: input.tools ?? { type: "preset", preset: "claude_code" },
2256 permissionMode: input.permissionMode ?? "default",
2257 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2258 ...(input.disallowedTools === undefined
2259 ? {}
2260 : { disallowedTools: input.disallowedTools }),
2261 ...(input.allowDangerouslySkipPermissions === true
2262 ? { allowDangerouslySkipPermissions: true }
2263 : {}),
2264 }
2265 : {}),
2266 });
2267 input.entries.push({
2268 type: "expect_outbound",
2269 label: "query.open:source-continuation",
2270 frame: makeClaudeQueryOpenFrame({ options: continuationOptions }),
2271 });
2272 const continuationRuntime = await openRecordingQuery({
2273 prompt: continuationPromptQueue,
2274 options: continuationOptions,
2275 });
2276 const continuationIterator = continuationRuntime[Symbol.asyncIterator]();
2277 for (const prompt of input.prompts.slice(forkPromptEnd)) {
2278 promptOrdinal += 1;
2279 const continuationMessage = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt });
2280 input.entries.push({
2281 type: "expect_outbound",
2282 label: `prompt.offer:${promptOrdinal}`,
2283 frame: makeClaudePromptOfferFrame(continuationMessage),
2284 });
2285 continuationPromptQueue.offer(continuationMessage);
2286 const completed = await recordMessagesUntilTurnResult({
2287 iterator: continuationIterator,
2288 entries: input.entries,
2289 scenario: input.scenario,
2290 });
2291 if (!completed) {
2292 throw new Error(
2293 `Claude source continuation ended before prompt ${promptOrdinal} completed.`,
2294 );
2295 }
2296 }
2297 continuationPromptQueue.close();
2298 continuationRuntime.close();
2299 input.entries.push({
2300 type: "runtime_exit",
2301 status: "success",
2302 });
2303 input.metadata.sourceContinuationPromptCount = sourceContinuationPromptCount;
2304 }
2305 } catch (error) {
2306 sourcePromptQueue.close();
2307 sourceRuntime.close();
2308 input.entries.push({
2309 type: "runtime_exit",
2310 status: "error",
2311 error: serializeReplayError(error, input.scenario),
2312 });
2313 throw error;
2314 }
2315}
2316
2317async function recordInterruptedClaudeQuery(input: {
2318 readonly scenario: string;
2319 readonly prompt: string;
2320 readonly modelSelection: ModelSelection;
2321 readonly cwd: string;
2322 readonly sessionId: string;
2323 readonly resume: boolean;
2324 readonly entries: Array<ProviderReplayEntry>;
2325 readonly queryOpenLabel: string;
2326 readonly promptOfferLabel: string;
2327 readonly interruptLabel: string;
2328 readonly interruptAfter?: "prompt_offer" | "tool_use";
2329 // With interruptAfter "tool_use": interrupt after this many root tool uses.
2330 readonly interruptAfterToolUses?: number;
2331 readonly enableTools?: boolean;
2332 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
2333 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
2334 readonly allowedTools?: ReadonlyArray<string>;
2335 readonly disallowedTools?: ReadonlyArray<string>;
2336 readonly allowDangerouslySkipPermissions?: boolean;
2337}): Promise<void> {
2338 const promptQueue = new RecordingPromptQueue();
2339 const options = ClaudeAdapterV2.makeClaudeQueryOptions({
2340 modelSelection: input.modelSelection,
2341 nativeThreadId: input.sessionId,
2342 resume: input.resume,
2343 cwd: input.cwd,
2344 ...(input.enableTools === true
2345 ? {
2346 tools: input.tools ?? { type: "preset", preset: "claude_code" },
2347 permissionMode: input.permissionMode ?? "default",
2348 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2349 ...(input.disallowedTools === undefined
2350 ? {}
2351 : { disallowedTools: input.disallowedTools }),
2352 ...(input.allowDangerouslySkipPermissions === true
2353 ? { allowDangerouslySkipPermissions: true }
2354 : {}),
2355 }
2356 : {}),
2357 });
2358 input.entries.push({
2359 type: "expect_outbound",
2360 label: input.queryOpenLabel,
2361 frame: makeClaudeQueryOpenFrame({ options }),
2362 });
2363 const runtime = await openRecordingQuery({
2364 prompt: promptQueue,
2365 options,
2366 });
2367 const iterator = runtime[Symbol.asyncIterator]();
2368 const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: input.prompt });
2369 input.entries.push({
2370 type: "expect_outbound",
2371 label: input.promptOfferLabel,
2372 frame: makeClaudePromptOfferFrame(message),
2373 });
2374 promptQueue.offer(message);
2375
2376 try {
2377 if (input.interruptAfter === "tool_use") {
2378 await recordMessagesUntilToolUse({
2379 iterator,
2380 entries: input.entries,
2381 scenario: input.scenario,
2382 toolUseCount: input.interruptAfterToolUses ?? 1,
2383 });
2384 await Effect.runPromise(Effect.sleep(Duration.millis(250)));
2385 }
2386 input.entries.push({
2387 type: "expect_outbound",
2388 label: input.interruptLabel,
2389 frame: { type: "query.interrupt" },
2390 });
2391
2392 let cancelledError: unknown;
2393 try {
2394 await runtime.interrupt();
2395 } catch (error) {
2396 cancelledError = error;
2397 }
2398 promptQueue.close();
2399 runtime.close();
2400 try {
2401 await recordMessagesUntilIteratorDone({
2402 iterator,
2403 entries: input.entries,
2404 scenario: input.scenario,
2405 });
2406 } catch (error) {
2407 cancelledError = error;
2408 }
2409 input.entries.push({
2410 type: "runtime_exit",
2411 status: cancelledError === undefined ? "success" : "cancelled",
2412 ...(cancelledError === undefined
2413 ? {}
2414 : { error: serializeReplayError(cancelledError, input.scenario) }),
2415 });
2416 } catch (error) {
2417 promptQueue.close();
2418 runtime.close();
2419 input.entries.push({
2420 type: "runtime_exit",
2421 status: "error",
2422 error: serializeReplayError(error, input.scenario),
2423 });
2424 throw error;
2425 }
2426}
2427
2428async function recordClaudeInterruptQuery(input: {
2429 readonly scenario: string;
2430 readonly prompts: ReadonlyArray<string>;
2431 readonly modelSelection: ModelSelection;
2432 readonly cwd: string;
2433 readonly sessionId: string;
2434 readonly entries: Array<ProviderReplayEntry>;
2435 readonly enableTools?: boolean;
2436 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
2437 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
2438 readonly allowedTools?: ReadonlyArray<string>;
2439 readonly disallowedTools?: ReadonlyArray<string>;
2440 readonly allowDangerouslySkipPermissions?: boolean;
2441 readonly interruptAfter?: "prompt_offer" | "tool_use";
2442 readonly interruptAfterToolUses?: number;
2443}): Promise<void> {
2444 if (input.prompts.length !== 1) {
2445 throw new Error(
2446 `Claude interrupt replay scenario ${input.scenario} requires exactly one prompt.`,
2447 );
2448 }
2449
2450 await recordInterruptedClaudeQuery({
2451 scenario: input.scenario,
2452 prompt: input.prompts[0]!,
2453 modelSelection: input.modelSelection,
2454 cwd: input.cwd,
2455 sessionId: input.sessionId,
2456 resume: false,
2457 entries: input.entries,
2458 queryOpenLabel: "query.open",
2459 promptOfferLabel: "prompt.offer:1",
2460 interruptLabel: "query.interrupt:1",
2461 ...(input.interruptAfter === undefined ? {} : { interruptAfter: input.interruptAfter }),
2462 ...(input.interruptAfterToolUses === undefined
2463 ? {}
2464 : { interruptAfterToolUses: input.interruptAfterToolUses }),
2465 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2466 ...(input.tools === undefined ? {} : { tools: input.tools }),
2467 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2468 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2469 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2470 ...(input.allowDangerouslySkipPermissions === undefined
2471 ? {}
2472 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2473 });
2474}
2475
2476async function recordClaudeInterruptRestartQuery(input: {
2477 readonly scenario: string;
2478 readonly prompts: ReadonlyArray<string>;
2479 readonly modelSelection: ModelSelection;
2480 readonly cwd: string;
2481 readonly sessionId: string;
2482 readonly entries: Array<ProviderReplayEntry>;
2483 readonly enableTools?: boolean;
2484 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
2485 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
2486 readonly allowedTools?: ReadonlyArray<string>;
2487 readonly disallowedTools?: ReadonlyArray<string>;
2488 readonly allowDangerouslySkipPermissions?: boolean;
2489 readonly interruptAfter?: "prompt_offer" | "tool_use";
2490}): Promise<void> {
2491 if (input.prompts.length !== 2) {
2492 throw new Error(
2493 `Claude interrupt-restart replay scenario ${input.scenario} requires exactly two prompts.`,
2494 );
2495 }
2496
2497 await recordInterruptedClaudeQuery({
2498 scenario: input.scenario,
2499 prompt: input.prompts[0]!,
2500 modelSelection: input.modelSelection,
2501 cwd: input.cwd,
2502 sessionId: input.sessionId,
2503 resume: false,
2504 entries: input.entries,
2505 queryOpenLabel: "query.open:1",
2506 promptOfferLabel: "prompt.offer:1",
2507 interruptLabel: "query.interrupt:1",
2508 ...(input.interruptAfter === undefined ? {} : { interruptAfter: input.interruptAfter }),
2509 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2510 ...(input.tools === undefined ? {} : { tools: input.tools }),
2511 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2512 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2513 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2514 ...(input.allowDangerouslySkipPermissions === undefined
2515 ? {}
2516 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2517 });
2518
2519 const secondPromptQueue = new RecordingPromptQueue();
2520 const secondOptions = ClaudeAdapterV2.makeClaudeQueryOptions({
2521 modelSelection: input.modelSelection,
2522 nativeThreadId: input.sessionId,
2523 resume: true,
2524 cwd: input.cwd,
2525 ...(input.enableTools === true
2526 ? {
2527 tools: input.tools ?? { type: "preset", preset: "claude_code" },
2528 permissionMode: input.permissionMode ?? "default",
2529 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2530 ...(input.disallowedTools === undefined
2531 ? {}
2532 : { disallowedTools: input.disallowedTools }),
2533 ...(input.allowDangerouslySkipPermissions === true
2534 ? { allowDangerouslySkipPermissions: true }
2535 : {}),
2536 }
2537 : {}),
2538 });
2539 input.entries.push({
2540 type: "expect_outbound",
2541 label: "query.open:2",
2542 frame: makeClaudeQueryOpenFrame({ options: secondOptions }),
2543 });
2544 const secondMessage = ClaudeAdapterV2.makeClaudeUserMessage({ text: input.prompts[1]! });
2545 input.entries.push({
2546 type: "expect_outbound",
2547 label: "prompt.offer:2",
2548 frame: makeClaudePromptOfferFrame(secondMessage),
2549 });
2550
2551 try {
2552 const secondRuntime = await openRecordingQuery({
2553 prompt: secondPromptQueue,
2554 options: secondOptions,
2555 });
2556 secondPromptQueue.offer(secondMessage);
2557 secondPromptQueue.close();
2558 const secondIterator = secondRuntime[Symbol.asyncIterator]();
2559 await recordMessagesUntilIteratorDone({
2560 iterator: secondIterator,
2561 entries: input.entries,
2562 scenario: input.scenario,
2563 });
2564 secondRuntime.close();
2565 input.entries.push({
2566 type: "runtime_exit",
2567 status: "success",
2568 });
2569 } catch (error) {
2570 secondPromptQueue.close();
2571 input.entries.push({
2572 type: "runtime_exit",
2573 status: "error",
2574 error: serializeReplayError(error, input.scenario),
2575 });
2576 throw error;
2577 }
2578}
2579
2580export async function recordClaudeAgentSdkReplayTranscript(input: {
2581 readonly scenario: string;
2582 readonly prompts: ReadonlyArray<string>;
2583 readonly modelSelection: ModelSelection;
2584 readonly cwd: string;
2585 readonly sessionId?: string;
2586 readonly queryMode?:
2587 | "streaming"
2588 | "restart"
2589 | "resume_at_cursor"
2590 | "fork_session"
2591 | "fork_session_prior_turn"
2592 | "fork_session_continue"
2593 | "fork_session_siblings"
2594 | "fork_session_merge_back"
2595 | "fork_session_merge_back_siblings"
2596 | "active_steering"
2597 | "interrupt"
2598 | "interrupt_restart";
2599 readonly enableTools?: boolean;
2600 readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools;
2601 readonly permissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions["permissionMode"];
2602 readonly allowedTools?: ReadonlyArray<string>;
2603 readonly disallowedTools?: ReadonlyArray<string>;
2604 readonly allowDangerouslySkipPermissions?: boolean;
2605 readonly enablePermissionCallback?: boolean;
2606 readonly permissionDecision?: ProviderApprovalDecision;
2607 readonly backgroundWakeCounts?: ReadonlyArray<number>;
2608 readonly offerNextPromptImmediately?: boolean;
2609 readonly interruptAfter?: "prompt_offer" | "tool_use";
2610 readonly interruptAfterToolUses?: number;
2611}): Promise<ClaudeAgentSdkReplayTranscript> {
2612 if (input.prompts.length === 0) {
2613 throw new Error(
2614 `Claude Agent SDK replay scenario ${input.scenario} needs at least one prompt.`,
2615 );
2616 }
2617
2618 const entries: Array<ProviderReplayEntry> = [];
2619 const sessionId = input.sessionId ?? (await Effect.runPromise(randomUuidV4));
2620 const queryMode = input.queryMode ?? "streaming";
2621 const recordingMetadata: Record<string, unknown> = {};
2622 if (queryMode === "streaming") {
2623 await recordClaudeStreamingQuery({
2624 scenario: input.scenario,
2625 prompts: input.prompts,
2626 modelSelection: input.modelSelection,
2627 cwd: input.cwd,
2628 sessionId,
2629 entries,
2630 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2631 ...(input.tools === undefined ? {} : { tools: input.tools }),
2632 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2633 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2634 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2635 ...(input.allowDangerouslySkipPermissions === undefined
2636 ? {}
2637 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2638 ...(input.enablePermissionCallback === undefined
2639 ? {}
2640 : { enablePermissionCallback: input.enablePermissionCallback }),
2641 ...(input.backgroundWakeCounts === undefined
2642 ? {}
2643 : { backgroundWakeCounts: input.backgroundWakeCounts }),
2644 ...(input.offerNextPromptImmediately === undefined
2645 ? {}
2646 : { offerNextPromptImmediately: input.offerNextPromptImmediately }),
2647 ...(input.permissionDecision === undefined
2648 ? {}
2649 : { permissionDecision: input.permissionDecision }),
2650 });
2651 } else if (queryMode === "active_steering") {
2652 await recordClaudeActiveSteeringQuery({
2653 scenario: input.scenario,
2654 prompts: input.prompts,
2655 modelSelection: input.modelSelection,
2656 cwd: input.cwd,
2657 sessionId,
2658 entries,
2659 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2660 ...(input.tools === undefined ? {} : { tools: input.tools }),
2661 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2662 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2663 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2664 ...(input.allowDangerouslySkipPermissions === undefined
2665 ? {}
2666 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2667 ...(input.enablePermissionCallback === undefined
2668 ? {}
2669 : { enablePermissionCallback: input.enablePermissionCallback }),
2670 ...(input.permissionDecision === undefined
2671 ? {}
2672 : { permissionDecision: input.permissionDecision }),
2673 });
2674 } else if (queryMode === "restart") {
2675 await recordClaudeRestartingQueries({
2676 scenario: input.scenario,
2677 prompts: input.prompts,
2678 modelSelection: input.modelSelection,
2679 cwd: input.cwd,
2680 sessionId,
2681 entries,
2682 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2683 ...(input.tools === undefined ? {} : { tools: input.tools }),
2684 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2685 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2686 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2687 ...(input.allowDangerouslySkipPermissions === undefined
2688 ? {}
2689 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2690 });
2691 } else if (queryMode === "resume_at_cursor") {
2692 await recordClaudeResumeAtCursorQuery({
2693 scenario: input.scenario,
2694 prompts: input.prompts,
2695 modelSelection: input.modelSelection,
2696 cwd: input.cwd,
2697 sessionId,
2698 entries,
2699 metadata: recordingMetadata,
2700 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2701 ...(input.tools === undefined ? {} : { tools: input.tools }),
2702 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2703 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2704 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2705 ...(input.allowDangerouslySkipPermissions === undefined
2706 ? {}
2707 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2708 });
2709 } else if (
2710 queryMode === "fork_session" ||
2711 queryMode === "fork_session_prior_turn" ||
2712 queryMode === "fork_session_continue" ||
2713 queryMode === "fork_session_siblings" ||
2714 queryMode === "fork_session_merge_back" ||
2715 queryMode === "fork_session_merge_back_siblings"
2716 ) {
2717 await recordClaudeForkSessionQuery({
2718 scenario: input.scenario,
2719 prompts: input.prompts,
2720 modelSelection: input.modelSelection,
2721 cwd: input.cwd,
2722 sessionId,
2723 entries,
2724 metadata: recordingMetadata,
2725 ...(queryMode === "fork_session_prior_turn" ? { forkFromPromptIndex: 1 as const } : {}),
2726 ...(queryMode === "fork_session_continue" ? { sourcePromptCount: 1 } : {}),
2727 ...(queryMode === "fork_session_siblings"
2728 ? {
2729 sourcePromptCount: 1,
2730 forkPromptGroups: [[input.prompts[1]!], [input.prompts[2]!]],
2731 }
2732 : {}),
2733 ...(queryMode === "fork_session_merge_back"
2734 ? {
2735 sourcePromptCount: 1,
2736 forkPromptGroups: [[input.prompts[1]!]],
2737 sourceContinuationPromptCount: 2,
2738 }
2739 : {}),
2740 ...(queryMode === "fork_session_merge_back_siblings"
2741 ? {
2742 sourcePromptCount: 1,
2743 forkPromptGroups: [[input.prompts[1]!], [input.prompts[2]!]],
2744 sourceContinuationPromptCount: 3,
2745 }
2746 : {}),
2747 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2748 ...(input.tools === undefined ? {} : { tools: input.tools }),
2749 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2750 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2751 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2752 ...(input.allowDangerouslySkipPermissions === undefined
2753 ? {}
2754 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2755 });
2756 } else if (queryMode === "interrupt") {
2757 await recordClaudeInterruptQuery({
2758 scenario: input.scenario,
2759 prompts: input.prompts,
2760 modelSelection: input.modelSelection,
2761 cwd: input.cwd,
2762 sessionId,
2763 entries,
2764 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2765 ...(input.tools === undefined ? {} : { tools: input.tools }),
2766 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2767 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2768 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2769 ...(input.allowDangerouslySkipPermissions === undefined
2770 ? {}
2771 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2772 ...(input.interruptAfter === undefined ? {} : { interruptAfter: input.interruptAfter }),
2773 ...(input.interruptAfterToolUses === undefined
2774 ? {}
2775 : { interruptAfterToolUses: input.interruptAfterToolUses }),
2776 });
2777 } else {
2778 await recordClaudeInterruptRestartQuery({
2779 scenario: input.scenario,
2780 prompts: input.prompts,
2781 modelSelection: input.modelSelection,
2782 cwd: input.cwd,
2783 sessionId,
2784 entries,
2785 ...(input.enableTools === undefined ? {} : { enableTools: input.enableTools }),
2786 ...(input.tools === undefined ? {} : { tools: input.tools }),
2787 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2788 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2789 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2790 ...(input.allowDangerouslySkipPermissions === undefined
2791 ? {}
2792 : { allowDangerouslySkipPermissions: input.allowDangerouslySkipPermissions }),
2793 ...(input.interruptAfter === undefined ? {} : { interruptAfter: input.interruptAfter }),
2794 });
2795 }
2796
2797 return {
2798 provider: ClaudeAdapterV2.CLAUDE_PROVIDER,
2799 protocol: CLAUDE_AGENT_SDK_REPLAY_PROTOCOL,
2800 version: "0.2.111",
2801 scenario: input.scenario,
2802 metadata: {
2803 prompts: [...input.prompts],
2804 model: input.modelSelection.model,
2805 nativeSessionId: sessionId,
2806 queryMode,
2807 tools: input.enableTools === true ? (input.tools ?? "claude_code") : "none",
2808 ...(input.permissionMode === undefined ? {} : { permissionMode: input.permissionMode }),
2809 ...(input.allowedTools === undefined ? {} : { allowedTools: input.allowedTools }),
2810 ...(input.disallowedTools === undefined ? {} : { disallowedTools: input.disallowedTools }),
2811 ...(input.enablePermissionCallback === undefined
2812 ? {}
2813 : { enablePermissionCallback: input.enablePermissionCallback }),
2814 ...(input.permissionDecision === undefined
2815 ? {}
2816 : { permissionDecision: input.permissionDecision }),
2817 ...(input.backgroundWakeCounts === undefined
2818 ? {}
2819 : { backgroundWakeCounts: [...input.backgroundWakeCounts] }),
2820 ...(input.offerNextPromptImmediately === true ? { offerNextPromptImmediately: true } : {}),
2821 ...(input.interruptAfter === undefined ? {} : { interruptAfter: input.interruptAfter }),
2822 ...(input.interruptAfterToolUses === undefined
2823 ? {}
2824 : { interruptAfterToolUses: input.interruptAfterToolUses }),
2825 generatedBy: "recordClaudeAgentSdkReplayTranscript",
2826 ...recordingMetadata,
2827 },
2828 entries,
2829 };
2830}
2831
2832const decodeClaudeAgentSdkReplayTranscript = Schema.decodeUnknownEffect(
2833 ClaudeAgentSdkReplayTranscript,
2834);
2835
2836export const ClaudeOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
2837 ClaudeAgentSdkReplayTranscript,
2838 ClaudeOrchestratorReplayHarnessError
2839> = {
2840 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
2841 decodeTranscript: (transcript) =>
2842 decodeClaudeAgentSdkReplayTranscript(transcript).pipe(
2843 Effect.mapError(
2844 (cause) =>
2845 new ClaudeReplayTranscriptDecodeError({
2846 ...metadataFromTranscript(transcript),
2847 cause,
2848 }),
2849 ),
2850 ),
2851 makeProviderAdapterRegistryLayer: (transcript, options) =>
2852 layerClaudeProviderAdapterRegistryReplay(transcript, options),
2853};
2854
2855/**
2856 * Replays one transcript across several orchestrator runtimes, the way a
2857 * server restart reopens the same native session with a fresh adapter.
2858 */
2859export function makeClaudeRestartReplayHarness(transcript: ClaudeAgentSdkReplayTranscript) {
2860 const queryRunner = makeReplayQueryRunner(transcript);
2861 return {
2862 harness: {
2863 ...ClaudeOrchestratorReplayHarness,
2864 makeProviderAdapterRegistryLayer: (replayed) =>
2865 layerClaudeProviderAdapterRegistryReplay(replayed, { queryRunner }),
2866 } satisfies typeof ClaudeOrchestratorReplayHarness,
2867 assertComplete: replayQueryRunnerService(transcript, queryRunner).assertComplete,
2868 };
2869}