1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import {
4 CommandId,
5 MessageId,
6 type OrchestrationV2Command,
7 type OrchestrationV2ThreadProjection,
8 ProviderInstanceId,
9 type ProviderReplayTranscript,
10 ThreadId,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Schema from "effect/Schema";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18
19import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts";
20import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts";
21import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
22import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
23import {
24 THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
25 THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
26 THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
27 THREAD_FORK_NATIVE_SOURCE_PROMPT,
28 THREAD_FORK_NATIVE_TARGET_PROMPT,
29} from "./fixtures/shared.ts";
30import { runOrchestratorV2ProviderReplayScenario } from "./ProviderReplayHarness.ts";
31import {
32 decodeProviderReplayNdjson,
33 materializeReplayTranscriptWorkspace,
34} from "@t3tools/provider-testing/replayTranscript";
35
36const CODEX_MODEL_SELECTION = {
37 instanceId: ProviderInstanceId.make("codex"),
38 model: "gpt-5.4",
39} as const;
40const CLAUDE_MODEL_SELECTION = {
41 instanceId: ProviderInstanceId.make("claudeAgent"),
42 model: "claude-sonnet-4-6",
43} as const;
44const TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native/codex_transcript.ndjson`;
45const PRIOR_TURN_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson`;
46const CLAUDE_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native/claude_transcript.ndjson`;
47const CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson`;
48const CLAUDE_FORK_LOCAL_ROLLBACK_TRANSCRIPT_PATH = `${import.meta.dirname}/fixtures/thread_fork_native_fork_local_rollback/claude_transcript.ndjson`;
49const CODEX_READ_ONLY_NEVER_POLICY = {
50 approvalPolicy: "never",
51 sandboxPolicy: {
52 type: "readOnly",
53 access: { type: "fullAccess" },
54 networkAccess: false,
55 },
56} as const;
57
58class ThreadForkGitCommandError extends Schema.TaggedError<ThreadForkGitCommandError>()(
59 "ThreadForkGitCommandError",
60 {
61 command: Schema.String,
62 exitCode: Schema.Number,
63 },
64) {
65 override get message(): string {
66 return `${this.command} failed with exit ${this.exitCode}.`;
67 }
68}
69
70function runGit(
71 cwd: string,
72 args: ReadonlyArray<string>,
73): Effect.Effect<
74 void,
75 ThreadForkGitCommandError | PlatformError.PlatformError,
76 ChildProcessSpawner.ChildProcessSpawner
77> {
78 return Effect.gen(function* () {
79 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
80 const exitCode = yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }));
81 if (Number(exitCode) !== 0) {
82 return yield* new ThreadForkGitCommandError({
83 command: `git ${args.join(" ")}`,
84 exitCode: Number(exitCode),
85 });
86 }
87 });
88}
89
90const makeCheckpointWorkspace = Effect.gen(function* () {
91 const fs = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const cwd = yield* fs.makeTempDirectory({ prefix: "t3-orchestrator-v2-thread-fork-" });
94 yield* runGit(cwd, ["init"]);
95 yield* runGit(cwd, ["config", "user.name", "T3 Code Test"]);
96 yield* runGit(cwd, ["config", "user.email", "t3code-test@example.com"]);
97 yield* fs.writeFileString(path.join(cwd, "README.md"), "# thread fork\n");
98 yield* runGit(cwd, ["add", "README.md"]);
99 yield* runGit(cwd, ["commit", "-m", "initial"]);
100 return cwd;
101});
102
103function readTranscript(transcriptPath: string = TRANSCRIPT_PATH) {
104 return Effect.gen(function* () {
105 const fs = yield* FileSystem.FileSystem;
106 const text = yield* fs.readFileString(transcriptPath);
107 return yield* decodeProviderReplayNdjson(text);
108 });
109}
110
111function metadataString(transcript: ProviderReplayTranscript, key: string): string {
112 const value = transcript.metadata?.[key];
113 if (typeof value !== "string") {
114 throw new Error(`Transcript ${transcript.scenario} is missing metadata string ${key}.`);
115 }
116 return value;
117}
118
119function metadataStringArray(
120 transcript: ProviderReplayTranscript,
121 key: string,
122): ReadonlyArray<string> {
123 const value = transcript.metadata?.[key];
124 if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
125 throw new Error(`Transcript ${transcript.scenario} is missing metadata string array ${key}.`);
126 }
127 return value;
128}
129
130function userAndAssistantText(
131 projection: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems">,
132): string {
133 return projection.visibleTurnItems
134 .flatMap((row) => {
135 const item = row.item;
136 return item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [];
137 })
138 .join("\n");
139}
140
141describe("orchestration V2 thread fork", () => {
142 it.effect(
143 "creates an idle app fork and resolves it with Codex native thread/fork on first dispatch",
144 () =>
145 Effect.gen(function* () {
146 const rawTranscript = yield* readTranscript();
147 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
148 Effect.service(FileSystem.FileSystem).pipe(
149 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
150 Effect.orDie,
151 ),
152 );
153 const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript(
154 materializeReplayTranscriptWorkspace(rawTranscript, cwd),
155 );
156
157 const materialized = yield* Effect.gen(function* () {
158 const ids = yield* IdAllocator.IdAllocatorV2;
159 const projectId = yield* ids.allocate.project({ fixtureName: "thread-fork-native" });
160 const sourceThreadId = yield* ids.allocate.thread({
161 fixtureName: "thread-fork-native-source",
162 projectId,
163 });
164 const targetThreadId = ThreadId.make("thread-fork-native-target");
165
166 const commands = [
167 {
168 type: "thread.create",
169 createdBy: "user",
170 creationSource: "web",
171 commandId: yield* ids.allocate.command({
172 fixtureName: "thread-fork-native",
173 commandName: "thread-create-source",
174 }),
175 threadId: sourceThreadId,
176 projectId,
177 title: "Source thread",
178 modelSelection: CODEX_MODEL_SELECTION,
179 runtimeMode: "full-access",
180 interactionMode: "default",
181 branch: null,
182 worktreePath: null,
183 },
184 {
185 type: "message.dispatch",
186 createdBy: "user",
187 creationSource: "web",
188 commandId: yield* ids.allocate.command({
189 fixtureName: "thread-fork-native",
190 commandName: "source-message",
191 }),
192 threadId: sourceThreadId,
193 messageId: MessageId.make("message-thread-fork-native-source"),
194 text: THREAD_FORK_NATIVE_SOURCE_PROMPT,
195 attachments: [],
196 modelSelection: CODEX_MODEL_SELECTION,
197 dispatchMode: { type: "start_immediately" },
198 },
199 {
200 type: "thread.fork",
201 createdBy: "user",
202 creationSource: "web",
203 commandId: CommandId.make("command-thread-fork-native"),
204 sourceThreadId,
205 targetThreadId,
206 sourcePoint: { type: "latest_stable" },
207 title: "Forked thread",
208 },
209 {
210 type: "thread.fork",
211 createdBy: "user",
212 creationSource: "web",
213 commandId: CommandId.make("command-thread-fork-native"),
214 sourceThreadId,
215 targetThreadId,
216 sourcePoint: { type: "latest_stable" },
217 title: "Forked thread",
218 },
219 {
220 type: "message.dispatch",
221 createdBy: "user",
222 creationSource: "web",
223 commandId: yield* ids.allocate.command({
224 fixtureName: "thread-fork-native",
225 commandName: "target-message",
226 }),
227 threadId: targetThreadId,
228 messageId: MessageId.make("message-thread-fork-native-target"),
229 text: THREAD_FORK_NATIVE_TARGET_PROMPT,
230 attachments: [],
231 modelSelection: CODEX_MODEL_SELECTION,
232 dispatchMode: { type: "start_immediately" },
233 },
234 ] satisfies ReadonlyArray<OrchestrationV2Command>;
235
236 return {
237 sourceThreadId,
238 targetThreadId,
239 commands,
240 };
241 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
242
243 const result = yield* runOrchestratorV2ProviderReplayScenario(
244 {
245 name: "thread_fork_native/codex",
246 transcript,
247 commands: materialized.commands,
248 steps: [
249 { type: "dispatch", command: materialized.commands[0]!, await: true },
250 { type: "advance_clock", duration: "1 millis" },
251 { type: "dispatch", command: materialized.commands[1]!, await: true },
252 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
253 { type: "dispatch", command: materialized.commands[2]!, await: true },
254 { type: "dispatch", command: materialized.commands[3]!, await: true },
255 { type: "dispatch", command: materialized.commands[4]!, await: true },
256 { type: "await_thread_idle", threadId: materialized.targetThreadId },
257 ],
258 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
259 runtimePolicyOverride: { cwd },
260 },
261 CodexOrchestratorReplayHarness,
262 ).pipe(provideDeterministicTestRuntime);
263
264 const sourceProjection = result.projections.get(materialized.sourceThreadId);
265 const targetProjection = result.projections.get(materialized.targetThreadId);
266 assert.isDefined(sourceProjection);
267 assert.isDefined(targetProjection);
268 assert.equal(targetProjection.thread.lineage.parentThreadId, materialized.sourceThreadId);
269 assert.equal(targetProjection.thread.lineage.relationshipToParent, "fork");
270 assert.lengthOf(targetProjection.providerSessions, 1);
271 assert.lengthOf(targetProjection.providerThreads, 1);
272 assert.equal(
273 targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId,
274 "native-fork-thread",
275 );
276 assert.equal(
277 targetProjection.providerThreads[0]?.forkedFrom?.providerThreadId,
278 sourceProjection.providerThreads[0]?.id,
279 );
280
281 const transfers = targetProjection.contextTransfers.filter(
282 (transfer) => transfer.targetThreadId === materialized.targetThreadId,
283 );
284 assert.lengthOf(transfers, 1);
285 assert.equal(transfers[0]?.status, "consumed");
286 assert.equal(transfers[0]?.resolution?.strategy, "native_fork");
287 assert.equal(transfers[0]?.targetRunId, targetProjection.runs[0]?.id);
288
289 const transferCreatedIndex = result.domainEvents.findIndex(
290 (event) => event.type === "context-transfer.created",
291 );
292 const targetProviderSessionIndex = result.domainEvents.findIndex(
293 (event) =>
294 event.threadId === materialized.targetThreadId &&
295 event.type === "provider-session.updated",
296 );
297 const targetRunCreatedIndex = result.domainEvents.findIndex(
298 (event) => event.threadId === materialized.targetThreadId && event.type === "run.created",
299 );
300 assert.isAtLeast(transferCreatedIndex, 0);
301 assert.isAbove(targetProviderSessionIndex, transferCreatedIndex);
302 assert.isAbove(
303 targetProviderSessionIndex,
304 targetRunCreatedIndex,
305 "provider runtime state must be materialized only after the target run commits",
306 );
307
308 const forkEvents = result.domainEvents.filter(
309 (event) => event.type === "context-transfer.created",
310 );
311 assert.lengthOf(
312 forkEvents,
313 1,
314 "duplicate fork command must return the receipt without creating another transfer",
315 );
316 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
317 );
318
319 it.effect(
320 "creates an idle app fork and resolves it with Claude native session fork on first dispatch",
321 () =>
322 Effect.gen(function* () {
323 const rawTranscript = yield* readTranscript(CLAUDE_TRANSCRIPT_PATH);
324 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript);
325 const forkedNativeSessionId = metadataString(transcript, "forkedNativeSessionId");
326 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
327 Effect.service(FileSystem.FileSystem).pipe(
328 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
329 Effect.orDie,
330 ),
331 );
332
333 const materialized = yield* Effect.gen(function* () {
334 const ids = yield* IdAllocator.IdAllocatorV2;
335 const projectId = yield* ids.allocate.project({ fixtureName: "thread-fork-native" });
336 const sourceThreadId = yield* ids.allocate.thread({
337 fixtureName: "thread-fork-native-source",
338 projectId,
339 });
340 const targetThreadId = ThreadId.make("thread-fork-native-target");
341
342 const commands = [
343 {
344 type: "thread.create",
345 createdBy: "user",
346 creationSource: "web",
347 commandId: yield* ids.allocate.command({
348 fixtureName: "thread-fork-native",
349 commandName: "thread-create-source",
350 }),
351 threadId: sourceThreadId,
352 projectId,
353 title: "Source thread",
354 modelSelection: CLAUDE_MODEL_SELECTION,
355 runtimeMode: "full-access",
356 interactionMode: "default",
357 branch: null,
358 worktreePath: null,
359 },
360 {
361 type: "message.dispatch",
362 createdBy: "user",
363 creationSource: "web",
364 commandId: yield* ids.allocate.command({
365 fixtureName: "thread-fork-native",
366 commandName: "source-message",
367 }),
368 threadId: sourceThreadId,
369 messageId: MessageId.make("message-thread-fork-native-source"),
370 text: THREAD_FORK_NATIVE_SOURCE_PROMPT,
371 attachments: [],
372 modelSelection: CLAUDE_MODEL_SELECTION,
373 dispatchMode: { type: "start_immediately" },
374 },
375 {
376 type: "thread.fork",
377 createdBy: "user",
378 creationSource: "web",
379 commandId: CommandId.make("command-thread-fork-native"),
380 sourceThreadId,
381 targetThreadId,
382 sourcePoint: { type: "latest_stable" },
383 title: "Forked thread",
384 },
385 {
386 type: "message.dispatch",
387 createdBy: "user",
388 creationSource: "web",
389 commandId: yield* ids.allocate.command({
390 fixtureName: "thread-fork-native",
391 commandName: "target-message",
392 }),
393 threadId: targetThreadId,
394 messageId: MessageId.make("message-thread-fork-native-target"),
395 text: THREAD_FORK_NATIVE_TARGET_PROMPT,
396 attachments: [],
397 modelSelection: CLAUDE_MODEL_SELECTION,
398 dispatchMode: { type: "start_immediately" },
399 },
400 ] satisfies ReadonlyArray<OrchestrationV2Command>;
401
402 return {
403 sourceThreadId,
404 targetThreadId,
405 commands,
406 };
407 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
408
409 const result = yield* runOrchestratorV2ProviderReplayScenario(
410 {
411 name: "thread_fork_native/claude",
412 transcript,
413 commands: materialized.commands,
414 steps: [
415 { type: "dispatch", command: materialized.commands[0]!, await: true },
416 { type: "advance_clock", duration: "1 millis" },
417 { type: "dispatch", command: materialized.commands[1]!, await: true },
418 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
419 { type: "dispatch", command: materialized.commands[2]!, await: true },
420 { type: "dispatch", command: materialized.commands[3]!, await: true },
421 { type: "await_thread_idle", threadId: materialized.targetThreadId },
422 ],
423 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
424 runtimePolicyOverride: { cwd },
425 },
426 ClaudeOrchestratorReplayHarness,
427 ).pipe(provideDeterministicTestRuntime);
428
429 const sourceProjection = result.projections.get(materialized.sourceThreadId);
430 const targetProjection = result.projections.get(materialized.targetThreadId);
431 assert.isDefined(sourceProjection);
432 assert.isDefined(targetProjection);
433 assert.equal(
434 targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId,
435 forkedNativeSessionId,
436 );
437 assert.equal(
438 targetProjection.providerThreads[0]?.forkedFrom?.providerThreadId,
439 sourceProjection.providerThreads[0]?.id,
440 );
441 assert.include(
442 targetProjection.turnItems
443 .filter((item) => item.type === "assistant_message")
444 .map((item) => item.text)
445 .join("\n"),
446 "fork native ok",
447 );
448
449 const transfers = targetProjection.contextTransfers.filter(
450 (transfer) => transfer.targetThreadId === materialized.targetThreadId,
451 );
452 assert.lengthOf(transfers, 1);
453 assert.equal(transfers[0]?.status, "consumed");
454 assert.equal(transfers[0]?.resolution?.strategy, "native_fork");
455 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
456 );
457
458 it.effect("forks a Codex native thread at the selected native turn boundary", () =>
459 Effect.gen(function* () {
460 const rawTranscript = yield* readTranscript(PRIOR_TURN_TRANSCRIPT_PATH);
461 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
462 Effect.service(FileSystem.FileSystem).pipe(
463 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
464 Effect.orDie,
465 ),
466 );
467 const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript(
468 materializeReplayTranscriptWorkspace(rawTranscript, cwd),
469 );
470
471 const materialized = yield* Effect.gen(function* () {
472 const ids = yield* IdAllocator.IdAllocatorV2;
473 const projectId = yield* ids.allocate.project({
474 fixtureName: "thread-fork-native-prior-turn",
475 });
476 const sourceThreadId = yield* ids.allocate.thread({
477 fixtureName: "thread-fork-native-prior-turn-source",
478 projectId,
479 });
480 const targetThreadId = ThreadId.make("thread-fork-native-prior-turn-target");
481 const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 });
482
483 const commands = [
484 {
485 type: "thread.create",
486 createdBy: "user",
487 creationSource: "web",
488 commandId: yield* ids.allocate.command({
489 fixtureName: "thread-fork-native-prior-turn",
490 commandName: "thread-create-source",
491 }),
492 threadId: sourceThreadId,
493 projectId,
494 title: "Source thread",
495 modelSelection: CODEX_MODEL_SELECTION,
496 runtimeMode: "full-access",
497 interactionMode: "default",
498 branch: null,
499 worktreePath: null,
500 },
501 {
502 type: "message.dispatch",
503 createdBy: "user",
504 creationSource: "web",
505 commandId: yield* ids.allocate.command({
506 fixtureName: "thread-fork-native-prior-turn",
507 commandName: "source-message-alpha",
508 }),
509 threadId: sourceThreadId,
510 messageId: MessageId.make("message-thread-fork-native-prior-turn-alpha"),
511 text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
512 attachments: [],
513 modelSelection: CODEX_MODEL_SELECTION,
514 dispatchMode: { type: "start_immediately" },
515 },
516 {
517 type: "message.dispatch",
518 createdBy: "user",
519 creationSource: "web",
520 commandId: yield* ids.allocate.command({
521 fixtureName: "thread-fork-native-prior-turn",
522 commandName: "source-message-beta",
523 }),
524 threadId: sourceThreadId,
525 messageId: MessageId.make("message-thread-fork-native-prior-turn-beta"),
526 text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
527 attachments: [],
528 modelSelection: CODEX_MODEL_SELECTION,
529 dispatchMode: { type: "start_immediately" },
530 },
531 {
532 type: "thread.fork",
533 createdBy: "user",
534 creationSource: "web",
535 commandId: CommandId.make("command-thread-fork-native-prior-turn"),
536 sourceThreadId,
537 targetThreadId,
538 sourcePoint: { type: "run", runId: firstRunId },
539 title: "Forked from first response",
540 },
541 {
542 type: "message.dispatch",
543 createdBy: "user",
544 creationSource: "web",
545 commandId: yield* ids.allocate.command({
546 fixtureName: "thread-fork-native-prior-turn",
547 commandName: "target-message-repeat",
548 }),
549 threadId: targetThreadId,
550 messageId: MessageId.make("message-thread-fork-native-prior-turn-repeat"),
551 text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
552 attachments: [],
553 modelSelection: CODEX_MODEL_SELECTION,
554 dispatchMode: { type: "start_immediately" },
555 },
556 ] satisfies ReadonlyArray<OrchestrationV2Command>;
557
558 return {
559 sourceThreadId,
560 targetThreadId,
561 commands,
562 };
563 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
564
565 const result = yield* runOrchestratorV2ProviderReplayScenario(
566 {
567 name: "thread_fork_native_prior_turn/codex",
568 transcript,
569 commands: materialized.commands,
570 steps: [
571 { type: "dispatch", command: materialized.commands[0]!, await: true },
572 { type: "advance_clock", duration: "1 millis" },
573 { type: "dispatch", command: materialized.commands[1]!, await: true },
574 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
575 { type: "dispatch", command: materialized.commands[2]!, await: true },
576 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
577 { type: "dispatch", command: materialized.commands[3]!, await: true },
578 { type: "dispatch", command: materialized.commands[4]!, await: true },
579 { type: "await_thread_idle", threadId: materialized.targetThreadId },
580 ],
581 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
582 runtimePolicyOverride: { cwd, ...CODEX_READ_ONLY_NEVER_POLICY },
583 },
584 CodexOrchestratorReplayHarness,
585 ).pipe(provideDeterministicTestRuntime);
586
587 const targetProjection = result.projections.get(materialized.targetThreadId);
588 assert.isDefined(targetProjection);
589 const targetAssistantText = targetProjection.turnItems
590 .filter((item) => item.type === "assistant_message")
591 .map((item) => item.text)
592 .join("\n");
593 assert.include(targetAssistantText, "fork boundary alpha");
594 assert.notInclude(
595 targetAssistantText,
596 "fork boundary beta",
597 "forking from the first source run must not preserve later source turns in native Codex context",
598 );
599 assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork");
600
601 const visibleItems = targetProjection.visibleTurnItems.map((row) => row.item);
602 assert.deepEqual(
603 visibleItems.slice(0, 2).map((item) => item.type),
604 ["user_message", "assistant_message"],
605 "fork target projection should expose inherited source history through the fork point",
606 );
607 assert.equal(
608 visibleItems[0]?.type === "user_message" ? visibleItems[0].inputIntent : undefined,
609 "turn_start",
610 "inherited fork history should preserve source message intent",
611 );
612 assert.equal(targetProjection.visibleTurnItems[0]?.visibility, "inherited");
613 assert.equal(targetProjection.visibleTurnItems[1]?.visibility, "inherited");
614 const forkMarker = targetProjection.visibleTurnItems.find((row) => row.item.type === "fork");
615 assert.isDefined(forkMarker, "fork target projection should include a visible fork marker");
616 assert.equal(forkMarker.visibility, "synthetic");
617 const targetShell = result.shellSnapshot.threads.find(
618 (thread) => thread.id === materialized.targetThreadId,
619 );
620 assert.isDefined(targetShell, "shell snapshot should include the fork target thread");
621 assert.equal(targetShell.visibleItemCount, targetProjection.visibleTurnItems.length);
622 assert.equal(targetShell.lineage.relationshipToParent, "fork");
623 assert.equal(targetShell.forkedFrom?.type, "run");
624
625 const visibleText = visibleItems
626 .filter((item) => item.type === "user_message" || item.type === "assistant_message")
627 .map((item) => item.text)
628 .join("\n");
629 assert.include(visibleText, "fork boundary alpha");
630 assert.notInclude(
631 visibleText,
632 "fork boundary beta",
633 "fork target visible projection must not inherit source turns after the fork point",
634 );
635 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
636 );
637
638 it.effect("forks a Claude native session from an earlier completed source turn", () =>
639 Effect.gen(function* () {
640 const rawTranscript = yield* readTranscript(CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH);
641 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript);
642 const forkedNativeSessionId = metadataString(transcript, "forkedNativeSessionId");
643 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
644 Effect.service(FileSystem.FileSystem).pipe(
645 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
646 Effect.orDie,
647 ),
648 );
649
650 const materialized = yield* Effect.gen(function* () {
651 const ids = yield* IdAllocator.IdAllocatorV2;
652 const projectId = yield* ids.allocate.project({
653 fixtureName: "thread-fork-native-prior-turn",
654 });
655 const sourceThreadId = yield* ids.allocate.thread({
656 fixtureName: "thread-fork-native-prior-turn-source",
657 projectId,
658 });
659 const targetThreadId = ThreadId.make("thread-fork-native-prior-turn-target");
660 const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 });
661
662 const commands = [
663 {
664 type: "thread.create",
665 createdBy: "user",
666 creationSource: "web",
667 commandId: yield* ids.allocate.command({
668 fixtureName: "thread-fork-native-prior-turn",
669 commandName: "thread-create-source",
670 }),
671 threadId: sourceThreadId,
672 projectId,
673 title: "Source thread",
674 modelSelection: CLAUDE_MODEL_SELECTION,
675 runtimeMode: "full-access",
676 interactionMode: "default",
677 branch: null,
678 worktreePath: null,
679 },
680 {
681 type: "message.dispatch",
682 createdBy: "user",
683 creationSource: "web",
684 commandId: yield* ids.allocate.command({
685 fixtureName: "thread-fork-native-prior-turn",
686 commandName: "source-message-alpha",
687 }),
688 threadId: sourceThreadId,
689 messageId: MessageId.make("message-thread-fork-native-prior-turn-alpha"),
690 text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
691 attachments: [],
692 modelSelection: CLAUDE_MODEL_SELECTION,
693 dispatchMode: { type: "start_immediately" },
694 },
695 {
696 type: "message.dispatch",
697 createdBy: "user",
698 creationSource: "web",
699 commandId: yield* ids.allocate.command({
700 fixtureName: "thread-fork-native-prior-turn",
701 commandName: "source-message-beta",
702 }),
703 threadId: sourceThreadId,
704 messageId: MessageId.make("message-thread-fork-native-prior-turn-beta"),
705 text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
706 attachments: [],
707 modelSelection: CLAUDE_MODEL_SELECTION,
708 dispatchMode: { type: "start_immediately" },
709 },
710 {
711 type: "thread.fork",
712 createdBy: "user",
713 creationSource: "web",
714 commandId: CommandId.make("command-thread-fork-native-prior-turn"),
715 sourceThreadId,
716 targetThreadId,
717 sourcePoint: { type: "run", runId: firstRunId },
718 title: "Forked from first response",
719 },
720 {
721 type: "message.dispatch",
722 createdBy: "user",
723 creationSource: "web",
724 commandId: yield* ids.allocate.command({
725 fixtureName: "thread-fork-native-prior-turn",
726 commandName: "target-message-repeat",
727 }),
728 threadId: targetThreadId,
729 messageId: MessageId.make("message-thread-fork-native-prior-turn-repeat"),
730 text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
731 attachments: [],
732 modelSelection: CLAUDE_MODEL_SELECTION,
733 dispatchMode: { type: "start_immediately" },
734 },
735 ] satisfies ReadonlyArray<OrchestrationV2Command>;
736
737 return {
738 sourceThreadId,
739 targetThreadId,
740 commands,
741 };
742 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
743
744 const result = yield* runOrchestratorV2ProviderReplayScenario(
745 {
746 name: "thread_fork_native_prior_turn/claude",
747 transcript,
748 commands: materialized.commands,
749 steps: [
750 { type: "dispatch", command: materialized.commands[0]!, await: true },
751 { type: "advance_clock", duration: "1 millis" },
752 { type: "dispatch", command: materialized.commands[1]!, await: true },
753 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
754 { type: "dispatch", command: materialized.commands[2]!, await: true },
755 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
756 { type: "dispatch", command: materialized.commands[3]!, await: true },
757 { type: "dispatch", command: materialized.commands[4]!, await: true },
758 { type: "await_thread_idle", threadId: materialized.targetThreadId },
759 ],
760 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
761 runtimePolicyOverride: { cwd },
762 },
763 ClaudeOrchestratorReplayHarness,
764 ).pipe(provideDeterministicTestRuntime);
765
766 const targetProjection = result.projections.get(materialized.targetThreadId);
767 assert.isDefined(targetProjection);
768 assert.equal(
769 targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId,
770 forkedNativeSessionId,
771 );
772 const targetAssistantText = targetProjection.turnItems
773 .filter((item) => item.type === "assistant_message")
774 .map((item) => item.text)
775 .join("\n");
776 assert.include(targetAssistantText, "fork boundary alpha");
777 assert.notInclude(
778 targetAssistantText,
779 "fork boundary beta",
780 "forking from the first source run must not preserve later source turns in native Claude context",
781 );
782 assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork");
783 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
784 );
785
786 it.effect("keeps a Claude native fork stable when the source thread rolls back", () =>
787 Effect.gen(function* () {
788 const rawTranscript = yield* readTranscript(CLAUDE_PRIOR_TURN_TRANSCRIPT_PATH);
789 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript);
790 const forkedNativeSessionId = metadataString(transcript, "forkedNativeSessionId");
791 const sourceAssistantMessageUuids = metadataStringArray(
792 transcript,
793 "sourceAssistantMessageUuids",
794 );
795 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
796 Effect.service(FileSystem.FileSystem).pipe(
797 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
798 Effect.orDie,
799 ),
800 );
801
802 const materialized = yield* Effect.gen(function* () {
803 const ids = yield* IdAllocator.IdAllocatorV2;
804 const projectId = yield* ids.allocate.project({
805 fixtureName: "thread-fork-native-prior-turn-source-rollback",
806 });
807 const sourceThreadId = yield* ids.allocate.thread({
808 fixtureName: "thread-fork-native-prior-turn-source-rollback-source",
809 projectId,
810 });
811 const targetThreadId = ThreadId.make(
812 "thread-fork-native-prior-turn-source-rollback-target",
813 );
814 const firstRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 1 });
815 const secondRunId = ids.derive.run({ threadId: sourceThreadId, ordinal: 2 });
816 const checkpointScopeId = yield* ids.allocate.checkpointScope({
817 threadId: sourceThreadId,
818 name: "root",
819 });
820 const firstCheckpointId = yield* ids.allocate.checkpoint({
821 checkpointScopeId,
822 name: "1",
823 });
824
825 const commands = [
826 {
827 type: "thread.create",
828 createdBy: "user",
829 creationSource: "web",
830 commandId: yield* ids.allocate.command({
831 fixtureName: "thread-fork-native-prior-turn-source-rollback",
832 commandName: "thread-create-source",
833 }),
834 threadId: sourceThreadId,
835 projectId,
836 title: "Source thread",
837 modelSelection: CLAUDE_MODEL_SELECTION,
838 runtimeMode: "full-access",
839 interactionMode: "default",
840 branch: null,
841 worktreePath: null,
842 },
843 {
844 type: "message.dispatch",
845 createdBy: "user",
846 creationSource: "web",
847 commandId: yield* ids.allocate.command({
848 fixtureName: "thread-fork-native-prior-turn-source-rollback",
849 commandName: "source-message-alpha",
850 }),
851 threadId: sourceThreadId,
852 messageId: MessageId.make(
853 "message-thread-fork-native-prior-turn-source-rollback-alpha",
854 ),
855 text: THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
856 attachments: [],
857 modelSelection: CLAUDE_MODEL_SELECTION,
858 dispatchMode: { type: "start_immediately" },
859 },
860 {
861 type: "message.dispatch",
862 createdBy: "user",
863 creationSource: "web",
864 commandId: yield* ids.allocate.command({
865 fixtureName: "thread-fork-native-prior-turn-source-rollback",
866 commandName: "source-message-beta",
867 }),
868 threadId: sourceThreadId,
869 messageId: MessageId.make("message-thread-fork-native-prior-turn-source-rollback-beta"),
870 text: THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
871 attachments: [],
872 modelSelection: CLAUDE_MODEL_SELECTION,
873 dispatchMode: { type: "start_immediately" },
874 },
875 {
876 type: "thread.fork",
877 createdBy: "user",
878 creationSource: "web",
879 commandId: CommandId.make("command-thread-fork-native-prior-turn-source-rollback"),
880 sourceThreadId,
881 targetThreadId,
882 sourcePoint: { type: "run", runId: firstRunId },
883 title: "Forked from first response",
884 },
885 {
886 type: "message.dispatch",
887 createdBy: "user",
888 creationSource: "web",
889 commandId: yield* ids.allocate.command({
890 fixtureName: "thread-fork-native-prior-turn-source-rollback",
891 commandName: "target-message-repeat",
892 }),
893 threadId: targetThreadId,
894 messageId: MessageId.make(
895 "message-thread-fork-native-prior-turn-source-rollback-repeat",
896 ),
897 text: THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
898 attachments: [],
899 modelSelection: CLAUDE_MODEL_SELECTION,
900 dispatchMode: { type: "start_immediately" },
901 },
902 {
903 type: "checkpoint.rollback",
904 restoreFiles: false,
905 commandId: yield* ids.allocate.command({
906 fixtureName: "thread-fork-native-prior-turn-source-rollback",
907 commandName: "rollback-source-to-alpha",
908 }),
909 threadId: sourceThreadId,
910 scopeId: checkpointScopeId,
911 checkpointId: firstCheckpointId,
912 },
913 ] satisfies ReadonlyArray<OrchestrationV2Command>;
914
915 return {
916 sourceThreadId,
917 targetThreadId,
918 secondRunId,
919 commands,
920 };
921 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
922
923 const result = yield* runOrchestratorV2ProviderReplayScenario(
924 {
925 name: "thread_fork_native_prior_turn_source_rollback/claude",
926 transcript,
927 commands: materialized.commands,
928 steps: [
929 { type: "dispatch", command: materialized.commands[0]!, await: true },
930 { type: "advance_clock", duration: "1 millis" },
931 { type: "dispatch", command: materialized.commands[1]!, await: true },
932 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
933 { type: "dispatch", command: materialized.commands[2]!, await: true },
934 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
935 { type: "dispatch", command: materialized.commands[3]!, await: true },
936 { type: "dispatch", command: materialized.commands[4]!, await: true },
937 { type: "await_thread_idle", threadId: materialized.targetThreadId },
938 { type: "dispatch", command: materialized.commands[5]!, await: true },
939 {
940 type: "await_run_status",
941 threadId: materialized.sourceThreadId,
942 runId: materialized.secondRunId,
943 status: "rolled_back",
944 },
945 ],
946 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
947 runtimePolicyOverride: { cwd },
948 },
949 ClaudeOrchestratorReplayHarness,
950 ).pipe(provideDeterministicTestRuntime);
951
952 const sourceProjection = result.projections.get(materialized.sourceThreadId);
953 const targetProjection = result.projections.get(materialized.targetThreadId);
954 assert.isDefined(sourceProjection);
955 assert.isDefined(targetProjection);
956
957 assert.equal(
958 sourceProjection.runs.map((run) => run.status).join(","),
959 "completed,rolled_back",
960 );
961 assert.equal(
962 sourceProjection.providerThreads[0]?.nativeConversationHeadRef?.nativeId,
963 sourceAssistantMessageUuids[0],
964 "source rollback should persist the Claude resume cursor for the first assistant message",
965 );
966 assert.equal(
967 targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId,
968 forkedNativeSessionId,
969 );
970 assert.equal(targetProjection.providerThreads[0]?.nativeConversationHeadRef, null);
971
972 const sourceVisibleText = userAndAssistantText(sourceProjection);
973 assert.include(sourceVisibleText, "fork boundary alpha");
974 assert.notInclude(sourceVisibleText, "fork boundary beta");
975
976 const targetVisibleText = userAndAssistantText(targetProjection);
977 assert.include(targetVisibleText, "fork boundary alpha");
978 assert.notInclude(
979 targetVisibleText,
980 "fork boundary beta",
981 "source rollback must not cause the fork target to inherit turns past its fork point",
982 );
983 assert.equal(targetProjection.contextTransfers[0]?.resolution?.strategy, "native_fork");
984 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
985 );
986
987 it.effect("rolls back a Claude native fork to an earlier fork-local turn", () =>
988 Effect.gen(function* () {
989 const rawTranscript = yield* readTranscript(CLAUDE_FORK_LOCAL_ROLLBACK_TRANSCRIPT_PATH);
990 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript(rawTranscript);
991 const forkedNativeSessionId = metadataString(transcript, "forkedNativeSessionId");
992 const resumeSessionAt = metadataString(transcript, "resumeSessionAt");
993 const prompts = metadataStringArray(transcript, "prompts");
994 const [sourcePrompt, forkFirstPrompt, forkSecondPrompt, repeatPrompt] = prompts;
995 if (
996 sourcePrompt === undefined ||
997 forkFirstPrompt === undefined ||
998 forkSecondPrompt === undefined ||
999 repeatPrompt === undefined
1000 ) {
1001 throw new Error("Claude fork-local rollback transcript is missing expected prompts.");
1002 }
1003
1004 const cwd = yield* Effect.acquireRelease(makeCheckpointWorkspace, (directory) =>
1005 Effect.service(FileSystem.FileSystem).pipe(
1006 Effect.flatMap((fs) => fs.remove(directory, { recursive: true, force: true })),
1007 Effect.orDie,
1008 ),
1009 );
1010
1011 const materialized = yield* Effect.gen(function* () {
1012 const ids = yield* IdAllocator.IdAllocatorV2;
1013 const projectId = yield* ids.allocate.project({
1014 fixtureName: "thread-fork-native-fork-local-rollback",
1015 });
1016 const sourceThreadId = yield* ids.allocate.thread({
1017 fixtureName: "thread-fork-native-fork-local-rollback-source",
1018 projectId,
1019 });
1020 const targetThreadId = ThreadId.make("thread-fork-native-fork-local-rollback-target");
1021 const targetSecondRunId = ids.derive.run({ threadId: targetThreadId, ordinal: 2 });
1022 const targetCheckpointScopeId = yield* ids.allocate.checkpointScope({
1023 threadId: targetThreadId,
1024 name: "root",
1025 });
1026 const targetFirstCheckpointId = yield* ids.allocate.checkpoint({
1027 checkpointScopeId: targetCheckpointScopeId,
1028 name: "1",
1029 });
1030
1031 const commands = [
1032 {
1033 type: "thread.create",
1034 createdBy: "user",
1035 creationSource: "web",
1036 commandId: yield* ids.allocate.command({
1037 fixtureName: "thread-fork-native-fork-local-rollback",
1038 commandName: "thread-create-source",
1039 }),
1040 threadId: sourceThreadId,
1041 projectId,
1042 title: "Source thread",
1043 modelSelection: CLAUDE_MODEL_SELECTION,
1044 runtimeMode: "full-access",
1045 interactionMode: "default",
1046 branch: null,
1047 worktreePath: null,
1048 },
1049 {
1050 type: "message.dispatch",
1051 createdBy: "user",
1052 creationSource: "web",
1053 commandId: yield* ids.allocate.command({
1054 fixtureName: "thread-fork-native-fork-local-rollback",
1055 commandName: "source-message",
1056 }),
1057 threadId: sourceThreadId,
1058 messageId: MessageId.make("message-thread-fork-native-fork-local-rollback-source"),
1059 text: sourcePrompt,
1060 attachments: [],
1061 modelSelection: CLAUDE_MODEL_SELECTION,
1062 dispatchMode: { type: "start_immediately" },
1063 },
1064 {
1065 type: "thread.fork",
1066 createdBy: "user",
1067 creationSource: "web",
1068 commandId: CommandId.make("command-thread-fork-native-fork-local-rollback"),
1069 sourceThreadId,
1070 targetThreadId,
1071 sourcePoint: { type: "latest_stable" },
1072 title: "Forked thread",
1073 },
1074 {
1075 type: "message.dispatch",
1076 createdBy: "user",
1077 creationSource: "web",
1078 commandId: yield* ids.allocate.command({
1079 fixtureName: "thread-fork-native-fork-local-rollback",
1080 commandName: "fork-first-message",
1081 }),
1082 threadId: targetThreadId,
1083 messageId: MessageId.make("message-thread-fork-native-fork-local-rollback-first"),
1084 text: forkFirstPrompt,
1085 attachments: [],
1086 modelSelection: CLAUDE_MODEL_SELECTION,
1087 dispatchMode: { type: "start_immediately" },
1088 },
1089 {
1090 type: "message.dispatch",
1091 createdBy: "user",
1092 creationSource: "web",
1093 commandId: yield* ids.allocate.command({
1094 fixtureName: "thread-fork-native-fork-local-rollback",
1095 commandName: "fork-second-message",
1096 }),
1097 threadId: targetThreadId,
1098 messageId: MessageId.make("message-thread-fork-native-fork-local-rollback-second"),
1099 text: forkSecondPrompt,
1100 attachments: [],
1101 modelSelection: CLAUDE_MODEL_SELECTION,
1102 dispatchMode: { type: "start_immediately" },
1103 },
1104 {
1105 type: "checkpoint.rollback",
1106 restoreFiles: false,
1107 commandId: yield* ids.allocate.command({
1108 fixtureName: "thread-fork-native-fork-local-rollback",
1109 commandName: "rollback-fork-to-first",
1110 }),
1111 threadId: targetThreadId,
1112 scopeId: targetCheckpointScopeId,
1113 checkpointId: targetFirstCheckpointId,
1114 },
1115 {
1116 type: "message.dispatch",
1117 createdBy: "user",
1118 creationSource: "web",
1119 commandId: yield* ids.allocate.command({
1120 fixtureName: "thread-fork-native-fork-local-rollback",
1121 commandName: "fork-repeat-after-rollback",
1122 }),
1123 threadId: targetThreadId,
1124 messageId: MessageId.make("message-thread-fork-native-fork-local-rollback-repeat"),
1125 text: repeatPrompt,
1126 attachments: [],
1127 modelSelection: CLAUDE_MODEL_SELECTION,
1128 dispatchMode: { type: "start_immediately" },
1129 },
1130 ] satisfies ReadonlyArray<OrchestrationV2Command>;
1131
1132 return {
1133 sourceThreadId,
1134 targetThreadId,
1135 targetSecondRunId,
1136 commands,
1137 };
1138 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
1139
1140 const result = yield* runOrchestratorV2ProviderReplayScenario(
1141 {
1142 name: "thread_fork_native_fork_local_rollback/claude",
1143 transcript,
1144 commands: materialized.commands,
1145 steps: [
1146 { type: "dispatch", command: materialized.commands[0]!, await: true },
1147 { type: "advance_clock", duration: "1 millis" },
1148 { type: "dispatch", command: materialized.commands[1]!, await: true },
1149 { type: "await_thread_idle", threadId: materialized.sourceThreadId },
1150 { type: "dispatch", command: materialized.commands[2]!, await: true },
1151 { type: "dispatch", command: materialized.commands[3]!, await: true },
1152 { type: "await_thread_idle", threadId: materialized.targetThreadId },
1153 { type: "dispatch", command: materialized.commands[4]!, await: true },
1154 { type: "await_thread_idle", threadId: materialized.targetThreadId },
1155 { type: "dispatch", command: materialized.commands[5]!, await: true },
1156 {
1157 type: "await_run_status",
1158 threadId: materialized.targetThreadId,
1159 runId: materialized.targetSecondRunId,
1160 status: "rolled_back",
1161 },
1162 { type: "dispatch", command: materialized.commands[6]!, await: true },
1163 { type: "await_thread_idle", threadId: materialized.targetThreadId },
1164 ],
1165 projectionThreadIds: [materialized.sourceThreadId, materialized.targetThreadId],
1166 runtimePolicyOverride: { cwd },
1167 },
1168 ClaudeOrchestratorReplayHarness,
1169 ).pipe(provideDeterministicTestRuntime);
1170
1171 const targetProjection = result.projections.get(materialized.targetThreadId);
1172 assert.isDefined(targetProjection);
1173 assert.equal(
1174 targetProjection.providerThreads[0]?.nativeThreadRef?.nativeId,
1175 forkedNativeSessionId,
1176 );
1177 assert.isString(resumeSessionAt);
1178
1179 const targetVisibleText = userAndAssistantText(targetProjection);
1180 assert.include(targetVisibleText, "fork local source alpha");
1181 assert.include(targetVisibleText, "fork local first");
1182 assert.notInclude(
1183 targetVisibleText,
1184 "fork local second",
1185 "rolled back fork-local turns must disappear from the projected fork thread",
1186 );
1187
1188 const targetVisibleAssistantText = targetProjection.visibleTurnItems
1189 .map((row) => row.item)
1190 .filter((item) => item.type === "assistant_message")
1191 .map((item) => item.text)
1192 .join("\n");
1193 assert.notInclude(
1194 targetVisibleAssistantText,
1195 "fork local second",
1196 "resumeSessionAt must reopen the Claude fork before the rolled-back second fork turn",
1197 );
1198 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1199 );
1200});