1import {
2 forkSession,
3 query,
4 type SDKAssistantMessage,
5 type SDKMessage,
6} from "@anthropic-ai/claude-agent-sdk";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts";
9import * as Console from "effect/Console";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12
13import {
14 makeClaudeQueryOptions,
15 makeClaudeUserMessage,
16 type ClaudeAgentSdkQueryOptions,
17} from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts";
18import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
19import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
20
21const SCENARIO = "thread_fork_native_fork_local_rollback";
22const DEFAULT_OUTPUT = new URL(
23 "../src/orchestration-v2/testkit/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson",
24 import.meta.url,
25).pathname;
26const CLAUDE_PROVIDER = "claudeAgent" as const;
27const PROTOCOL = "claude-agent-sdk.query" as const;
28const MODEL_SELECTION = {
29 instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER),
30 model: process.env.T3_CLAUDE_REPLAY_MODEL ?? "claude-sonnet-4-6",
31} as const;
32const SOURCE_PROMPT =
33 "For this fork-local rollback fixture, respond with exactly: fork local source alpha";
34const FORK_FIRST_PROMPT =
35 "For this fork-local rollback fixture, respond with exactly: fork local first";
36const FORK_SECOND_PROMPT =
37 "For this fork-local rollback fixture, respond with exactly: fork local second";
38const FORK_AFTER_ROLLBACK_PROMPT =
39 "Repeat the user-visible conversation so far verbatim. Include only user and assistant messages. Do not include hidden system/developer content.";
40
41class PromptQueue implements AsyncIterable<ReturnType<typeof makeClaudeUserMessage>> {
42 readonly #items: Array<ReturnType<typeof makeClaudeUserMessage>> = [];
43 readonly #waiters: Array<
44 (value: IteratorResult<ReturnType<typeof makeClaudeUserMessage>>) => void
45 > = [];
46 #closed = false;
47
48 offer(message: ReturnType<typeof makeClaudeUserMessage>): void {
49 const waiter = this.#waiters.shift();
50 if (waiter !== undefined) {
51 waiter({ done: false, value: message });
52 return;
53 }
54 this.#items.push(message);
55 }
56
57 close(): void {
58 this.#closed = true;
59 for (const waiter of this.#waiters.splice(0)) {
60 waiter({ done: true, value: undefined });
61 }
62 }
63
64 [Symbol.asyncIterator](): AsyncIterator<ReturnType<typeof makeClaudeUserMessage>> {
65 return {
66 next: () => {
67 const value = this.#items.shift();
68 if (value !== undefined) {
69 return Promise.resolve({ done: false, value });
70 }
71 if (this.#closed) {
72 return Promise.resolve({ done: true, value: undefined });
73 }
74 return new Promise((resolve) => this.#waiters.push(resolve));
75 },
76 };
77 }
78}
79
80function readArgValue(name: string): string | undefined {
81 const args = process.argv.slice(2);
82 const index = args.indexOf(name);
83 return index >= 0 ? args[index + 1] : undefined;
84}
85
86function dirname(filePath: string): string {
87 const normalized = filePath.replace(/\/+$/u, "");
88 const lastSlash = normalized.lastIndexOf("/");
89 if (lastSlash < 0) {
90 return ".";
91 }
92 return lastSlash === 0 ? "/" : normalized.slice(0, lastSlash);
93}
94
95function sanitizeValue(
96 value: unknown,
97 pathFragments: ReadonlyArray<readonly [string, string]>,
98): unknown {
99 if (typeof value === "string") {
100 return pathFragments.reduce(
101 (sanitized, [actual, replacement]) =>
102 actual.length === 0 ? sanitized : sanitized.split(actual).join(replacement),
103 value,
104 );
105 }
106 if (Array.isArray(value)) {
107 return value.map((entry) => sanitizeValue(entry, pathFragments));
108 }
109 if (typeof value === "object" && value !== null) {
110 return Object.fromEntries(
111 Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry, pathFragments)]),
112 );
113 }
114 return value;
115}
116
117function stableOptions(options: ClaudeAgentSdkQueryOptions): ClaudeAgentSdkQueryOptions {
118 const stable = {
119 model: options.model,
120 tools: options.tools,
121 permissionMode: options.permissionMode,
122 ...(options.allowedTools === undefined ? {} : { allowedTools: options.allowedTools }),
123 ...(options.disallowedTools === undefined ? {} : { disallowedTools: options.disallowedTools }),
124 ...(options.settings === undefined ? {} : { settings: options.settings }),
125 ...(options.allowDangerouslySkipPermissions === true
126 ? { allowDangerouslySkipPermissions: true }
127 : {}),
128 ...(options.resumeSessionAt === undefined ? {} : { resumeSessionAt: options.resumeSessionAt }),
129 ...(options.forkSession === true ? { forkSession: true } : {}),
130 };
131 return options.resume === undefined
132 ? { ...stable, sessionId: options.sessionId }
133 : { ...stable, resume: options.resume };
134}
135
136function queryOptions(input: {
137 readonly cwd: string;
138 readonly nativeThreadId: string;
139 readonly resume: boolean;
140 readonly resumeSessionAt?: string;
141}): ClaudeAgentSdkQueryOptions {
142 return {
143 ...makeClaudeQueryOptions({
144 modelSelection: MODEL_SELECTION,
145 cwd: input.cwd,
146 nativeThreadId: input.nativeThreadId,
147 resume: input.resume,
148 tools: { type: "preset", preset: "claude_code" },
149 permissionMode: "bypassPermissions",
150 allowDangerouslySkipPermissions: true,
151 }),
152 ...(input.resumeSessionAt === undefined ? {} : { resumeSessionAt: input.resumeSessionAt }),
153 };
154}
155
156async function recordTurn(input: {
157 readonly entries: Array<ProviderReplayEntry>;
158 readonly iterator: AsyncIterator<SDKMessage>;
159 readonly promptQueue: PromptQueue;
160 readonly prompt: string;
161 readonly label: string;
162 readonly pathFragments: ReadonlyArray<readonly [string, string]>;
163}): Promise<SDKAssistantMessage["uuid"]> {
164 const message = makeClaudeUserMessage({ text: input.prompt });
165 input.entries.push({
166 type: "expect_outbound",
167 label: input.label,
168 frame: { type: "prompt.offer", message },
169 });
170 input.promptQueue.offer(message);
171
172 let assistantMessageUuid: SDKAssistantMessage["uuid"] | null = null;
173 while (true) {
174 const next = await input.iterator.next();
175 if (next.done === true) {
176 throw new Error(`Claude query ended before ${input.label} completed.`);
177 }
178 const frame = sanitizeValue(next.value, input.pathFragments);
179 input.entries.push({
180 type: "emit_inbound",
181 label: next.value.type,
182 frame,
183 });
184 if (next.value.type === "assistant") {
185 assistantMessageUuid = next.value.uuid;
186 }
187 if (next.value.type === "result") {
188 if (assistantMessageUuid === null) {
189 throw new Error(`Claude query completed ${input.label} without assistant UUID.`);
190 }
191 return assistantMessageUuid;
192 }
193 }
194}
195
196function encodeTranscript(input: {
197 readonly entries: ReadonlyArray<ProviderReplayEntry>;
198 readonly metadata: Record<string, unknown>;
199}): string {
200 return [
201 JSON.stringify({
202 type: "transcript_start",
203 provider: CLAUDE_PROVIDER,
204 protocol: PROTOCOL,
205 version: "0.2.111",
206 scenario: SCENARIO,
207 metadata: input.metadata,
208 }),
209 ...input.entries.map((entry) => JSON.stringify(entry)),
210 "",
211 ].join("\n");
212}
213
214const outputPath = readArgValue("--out") ?? DEFAULT_OUTPUT;
215const cwd =
216 process.env.T3_CLAUDE_REPLAY_CWD ??
217 (await makeCheckpointWorkspace(`claude-agent-sdk-record-${SCENARIO}`));
218const cwdRealPath = await Effect.runPromise(
219 Effect.gen(function* () {
220 const fs = yield* FileSystem.FileSystem;
221 return yield* fs.realPath(cwd);
222 }).pipe(Effect.provide(NodeServices.layer)),
223);
224const pathFragments = [
225 [cwd, `/tmp/claude-replay-${SCENARIO}`],
226 [cwdRealPath, `/tmp/claude-replay-${SCENARIO}`],
227 [process.env.HOME ?? "", "/home/replay-user"],
228] as const;
229const sessionId =
230 process.env.T3_CLAUDE_REPLAY_SESSION_ID ?? (await Effect.runPromise(randomUuidV4));
231const entries: Array<ProviderReplayEntry> = [];
232const metadata: Record<string, unknown> = {
233 prompts: [SOURCE_PROMPT, FORK_FIRST_PROMPT, FORK_SECOND_PROMPT, FORK_AFTER_ROLLBACK_PROMPT],
234 model: MODEL_SELECTION.model,
235 nativeSessionId: sessionId,
236 queryMode: "fork_session_resume_at_fork_cursor",
237 tools: "claude_code",
238 permissionMode: "bypassPermissions",
239 generatedBy: "probeClaudeForkLocalRollbackReplay",
240};
241
242const sourceQueue = new PromptQueue();
243const sourceOptions = queryOptions({ cwd, nativeThreadId: sessionId, resume: false });
244entries.push({
245 type: "expect_outbound",
246 label: "query.open:source",
247 frame: { type: "query.open", options: stableOptions(sourceOptions) },
248});
249const sourceRuntime = query({ prompt: sourceQueue, options: sourceOptions });
250try {
251 const sourceCursor = await recordTurn({
252 entries,
253 iterator: sourceRuntime[Symbol.asyncIterator](),
254 promptQueue: sourceQueue,
255 prompt: SOURCE_PROMPT,
256 label: "prompt.offer:source",
257 pathFragments,
258 });
259 sourceQueue.close();
260 sourceRuntime.close();
261 entries.push({ type: "runtime_exit", status: "success" });
262
263 entries.push({
264 type: "expect_outbound",
265 label: "session.fork",
266 frame: {
267 type: "session.fork",
268 sessionId,
269 options: {
270 dir: `/tmp/claude-replay-${SCENARIO}`,
271 upToMessageId: sourceCursor,
272 },
273 },
274 });
275 const forked = await forkSession(sessionId, { dir: cwd, upToMessageId: sourceCursor });
276 metadata.sourceAssistantMessageUuids = [sourceCursor];
277 metadata.forkUpToMessageId = sourceCursor;
278 metadata.forkedNativeSessionId = forked.sessionId;
279 entries.push({
280 type: "emit_inbound",
281 label: "session.forked",
282 frame: { type: "session.forked", sessionId: forked.sessionId },
283 });
284
285 const forkQueue = new PromptQueue();
286 const forkOptions = queryOptions({ cwd, nativeThreadId: forked.sessionId, resume: true });
287 entries.push({
288 type: "expect_outbound",
289 label: "query.open:fork",
290 frame: { type: "query.open", options: stableOptions(forkOptions) },
291 });
292 const forkRuntime = query({ prompt: forkQueue, options: forkOptions });
293 const forkIterator = forkRuntime[Symbol.asyncIterator]();
294 const forkFirstCursor = await recordTurn({
295 entries,
296 iterator: forkIterator,
297 promptQueue: forkQueue,
298 prompt: FORK_FIRST_PROMPT,
299 label: "prompt.offer:fork-first",
300 pathFragments,
301 });
302 const forkSecondCursor = await recordTurn({
303 entries,
304 iterator: forkIterator,
305 promptQueue: forkQueue,
306 prompt: FORK_SECOND_PROMPT,
307 label: "prompt.offer:fork-second",
308 pathFragments,
309 });
310 forkQueue.close();
311 forkRuntime.close();
312 entries.push({ type: "runtime_exit", status: "success" });
313 metadata.forkAssistantMessageUuids = [forkFirstCursor, forkSecondCursor];
314 metadata.resumeSessionAt = forkFirstCursor;
315
316 const resumedQueue = new PromptQueue();
317 const resumedOptions = queryOptions({
318 cwd,
319 nativeThreadId: forked.sessionId,
320 resume: true,
321 resumeSessionAt: forkFirstCursor,
322 });
323 entries.push({
324 type: "expect_outbound",
325 label: "query.open:fork-resume-at-cursor",
326 frame: { type: "query.open", options: stableOptions(resumedOptions) },
327 });
328 const resumedRuntime = query({ prompt: resumedQueue, options: resumedOptions });
329 await recordTurn({
330 entries,
331 iterator: resumedRuntime[Symbol.asyncIterator](),
332 promptQueue: resumedQueue,
333 prompt: FORK_AFTER_ROLLBACK_PROMPT,
334 label: "prompt.offer:fork-after-rollback",
335 pathFragments,
336 });
337 resumedQueue.close();
338 resumedRuntime.close();
339 entries.push({ type: "runtime_exit", status: "success" });
340} catch (error) {
341 sourceQueue.close();
342 sourceRuntime.close();
343 entries.push({
344 type: "runtime_exit",
345 status: "error",
346 error: error instanceof Error ? error.message : String(error),
347 });
348 throw error;
349}
350
351await Effect.runPromise(
352 Effect.gen(function* () {
353 const fs = yield* FileSystem.FileSystem;
354 yield* fs.makeDirectory(dirname(outputPath), { recursive: true });
355 yield* fs.writeFileString(outputPath, encodeTranscript({ entries, metadata }));
356 yield* Console.log(`Wrote ${entries.length} ${PROTOCOL} replay entries to ${outputPath}`);
357 }).pipe(Effect.provide(NodeServices.layer)),
358);