| 1 | import { |
| 2 | forkSession, |
| 3 | query, |
| 4 | type SDKAssistantMessage, |
| 5 | type SDKMessage, |
| 6 | } from "@anthropic-ai/claude-agent-sdk"; |
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts"; |
| 9 | import * as Console from "effect/Console"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | |
| 13 | import { |
| 14 | makeClaudeQueryOptions, |
| 15 | makeClaudeUserMessage, |
| 16 | type ClaudeAgentSdkQueryOptions, |
| 17 | } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 18 | import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; |
| 19 | import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 20 | |
| 21 | const SCENARIO = "thread_fork_native_fork_local_rollback"; |
| 22 | const 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; |
| 26 | const CLAUDE_PROVIDER = "claudeAgent" as const; |
| 27 | const PROTOCOL = "claude-agent-sdk.query" as const; |
| 28 | const MODEL_SELECTION = { |
| 29 | instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER), |
| 30 | model: process.env.T3_CLAUDE_REPLAY_MODEL ?? "claude-sonnet-4-6", |
| 31 | } as const; |
| 32 | const SOURCE_PROMPT = |
| 33 | "For this fork-local rollback fixture, respond with exactly: fork local source alpha"; |
| 34 | const FORK_FIRST_PROMPT = |
| 35 | "For this fork-local rollback fixture, respond with exactly: fork local first"; |
| 36 | const FORK_SECOND_PROMPT = |
| 37 | "For this fork-local rollback fixture, respond with exactly: fork local second"; |
| 38 | const 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 | |
| 41 | class 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 | |
| 80 | function 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 | |
| 86 | function 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 | |
| 95 | function 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 | |
| 117 | function 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 | |
| 136 | function 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 | |
| 156 | async 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 | |
| 196 | function 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 | |
| 214 | const outputPath = readArgValue("--out") ?? DEFAULT_OUTPUT; |
| 215 | const cwd = |
| 216 | process.env.T3_CLAUDE_REPLAY_CWD ?? |
| 217 | (await makeCheckpointWorkspace(`claude-agent-sdk-record-${SCENARIO}`)); |
| 218 | const 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 | ); |
| 224 | const pathFragments = [ |
| 225 | [cwd, `/tmp/claude-replay-${SCENARIO}`], |
| 226 | [cwdRealPath, `/tmp/claude-replay-${SCENARIO}`], |
| 227 | [process.env.HOME ?? "", "/home/replay-user"], |
| 228 | ] as const; |
| 229 | const sessionId = |
| 230 | process.env.T3_CLAUDE_REPLAY_SESSION_ID ?? (await Effect.runPromise(randomUuidV4)); |
| 231 | const entries: Array<ProviderReplayEntry> = []; |
| 232 | const 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 | |
| 242 | const sourceQueue = new PromptQueue(); |
| 243 | const sourceOptions = queryOptions({ cwd, nativeThreadId: sessionId, resume: false }); |
| 244 | entries.push({ |
| 245 | type: "expect_outbound", |
| 246 | label: "query.open:source", |
| 247 | frame: { type: "query.open", options: stableOptions(sourceOptions) }, |
| 248 | }); |
| 249 | const sourceRuntime = query({ prompt: sourceQueue, options: sourceOptions }); |
| 250 | try { |
| 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 | |
| 351 | await 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 | ); |