1import { assert, it } from "@effect/vitest";
2import {
3 ContextTransferId,
4 MessageId,
5 type ModelSelection,
6 type OrchestrationV2AppThread,
7 type OrchestrationV2Run,
8 type OrchestrationV2ThreadProjection,
9 ProjectId,
10 ProviderInstanceId,
11 ProviderThreadId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17
18import * as ThreadForkService from "./ThreadForkService.ts";
19
20const sourceThreadId = ThreadId.make("thread:fork-snoozed-source");
21const targetThreadId = ThreadId.make("thread:fork-awake-target");
22const sourceRunId = RunId.make("run:fork-snoozed-source");
23const providerInstanceId = ProviderInstanceId.make("codex");
24const modelSelection = {
25 instanceId: providerInstanceId,
26 model: "gpt-5.4",
27} satisfies ModelSelection;
28const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
29const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
30const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
31const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
32
33function makeSourceThread(): OrchestrationV2AppThread {
34 return {
35 createdBy: "user",
36 creationSource: "web",
37 id: sourceThreadId,
38 projectId: ProjectId.make("project:fork-snooze"),
39 title: "Snoozed source",
40 providerInstanceId,
41 modelSelection,
42 runtimeMode: "full-access",
43 interactionMode: "plan",
44 branch: "feature/source",
45 worktreePath: "/tmp/source-worktree",
46 activeProviderThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
47 lineage: {
48 parentThreadId: null,
49 relationshipToParent: null,
50 rootThreadId: sourceThreadId,
51 },
52 forkedFrom: null,
53 createdAt: sourceCreatedAt,
54 updatedAt: snoozedAt,
55 archivedAt: null,
56 settledOverride: null,
57 settledAt: null,
58 lastVisitedAt: null,
59 snoozedUntil,
60 snoozedAt,
61 deletedAt: null,
62 };
63}
64
65function makeSourceRun(status: OrchestrationV2Run["status"]): OrchestrationV2Run {
66 return {
67 id: sourceRunId,
68 threadId: sourceThreadId,
69 ordinal: 1,
70 providerInstanceId,
71 modelSelection,
72 providerThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
73 userMessageId: MessageId.make("message:fork-snoozed-source"),
74 rootNodeId: null,
75 activeAttemptId: null,
76 status,
77 queuePosition: null,
78 requestedAt: sourceCreatedAt,
79 startedAt: sourceCreatedAt,
80 completedAt: snoozedAt,
81 checkpointId: null,
82 contextHandoffId: null,
83 };
84}
85
86function makeSourceProjection(sourceRun: OrchestrationV2Run): OrchestrationV2ThreadProjection {
87 return {
88 thread: makeSourceThread(),
89 runs: [sourceRun],
90 attempts: [],
91 nodes: [],
92 subagents: [],
93 providerSessions: [],
94 providerThreads: [],
95 providerTurns: [],
96 runtimeRequests: [],
97 messages: [],
98 plans: [],
99 turnItems: [],
100 checkpointScopes: [],
101 checkpoints: [],
102 contextHandoffs: [],
103 contextTransfers: [],
104 visibleTurnItems: [],
105 updatedAt: snoozedAt,
106 };
107}
108
109const planFork = (sourceRun: OrchestrationV2Run) =>
110 Effect.gen(function* () {
111 const service = yield* ThreadForkService.ThreadForkServiceV2;
112 return yield* service.plan({
113 sourceProjection: makeSourceProjection(sourceRun),
114 sourceRun,
115 sourceProviderThread: undefined,
116 canonicalSourcePoint: {
117 threadId: sourceThreadId,
118 runId: sourceRunId,
119 },
120 transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"),
121 targetThreadId,
122 title: "Awake fork",
123 createdBy: "user",
124 creationSource: "mobile",
125 createdAt: forkCreatedAt,
126 });
127 }).pipe(Effect.provide(ThreadForkService.layer));
128
129it("treats usage-limited and other provider-finished runs as forkable", () => {
130 assert.isTrue(ThreadForkService.isForkableSourceRunStatus("completed"));
131 assert.isTrue(ThreadForkService.isForkableSourceRunStatus("waiting"));
132 assert.isTrue(ThreadForkService.isForkableSourceRunStatus("failed"));
133 assert.isTrue(ThreadForkService.isForkableSourceRunStatus("interrupted"));
134 assert.isTrue(ThreadForkService.isForkableSourceRunStatus("cancelled"));
135 assert.isFalse(ThreadForkService.isForkableSourceRunStatus("running"));
136 assert.isFalse(ThreadForkService.isForkableSourceRunStatus("starting"));
137 assert.isFalse(ThreadForkService.isForkableSourceRunStatus("queued"));
138 assert.isFalse(ThreadForkService.isForkableSourceRunStatus("preparing"));
139 assert.isFalse(ThreadForkService.isForkableSourceRunStatus("rolled_back"));
140});
141
142it.effect("keeps a fork awake when its source thread is snoozed", () =>
143 Effect.gen(function* () {
144 const sourceThread = makeSourceThread();
145 const sourceRun = makeSourceRun("completed");
146 const result = yield* planFork(sourceRun);
147
148 assert.isNull(result.targetThread.snoozedUntil);
149 assert.isNull(result.targetThread.snoozedAt);
150 assert.equal(result.targetThread.projectId, sourceThread.projectId);
151 assert.equal(result.targetThread.providerInstanceId, sourceThread.providerInstanceId);
152 assert.deepEqual(result.targetThread.modelSelection, sourceThread.modelSelection);
153 assert.equal(result.targetThread.runtimeMode, sourceThread.runtimeMode);
154 assert.equal(result.targetThread.interactionMode, sourceThread.interactionMode);
155 assert.equal(result.targetThread.branch, sourceThread.branch);
156 assert.equal(result.targetThread.worktreePath, sourceThread.worktreePath);
157 assert.isNull(result.targetThread.activeProviderThreadId);
158 assert.deepEqual(result.targetThread.lineage, {
159 parentThreadId: sourceThreadId,
160 relationshipToParent: "fork",
161 rootThreadId: sourceThreadId,
162 });
163 assert.deepEqual(result.targetThread.forkedFrom, {
164 type: "run",
165 threadId: sourceThreadId,
166 runId: sourceRunId,
167 });
168 }),
169);
170
171it.effect("forks from a usage-limited failed run", () =>
172 Effect.gen(function* () {
173 const result = yield* planFork(makeSourceRun("failed"));
174 assert.deepEqual(result.targetThread.forkedFrom, {
175 type: "run",
176 threadId: sourceThreadId,
177 runId: sourceRunId,
178 });
179 }),
180);
181
182it.effect("rejects in-progress and rolled-back fork sources", () =>
183 Effect.gen(function* () {
184 for (const status of ["running", "rolled_back"] as const) {
185 const sourceRun = makeSourceRun(status);
186 const error = yield* planFork(sourceRun).pipe(Effect.flip);
187 assert.equal(error._tag, "ThreadForkPlanError");
188 assert.equal(error.sourceThreadId, sourceThreadId);
189 assert.equal(error.targetThreadId, targetThreadId);
190 assert.equal(error.cause, ThreadForkService.forkableSourceRunStatusError(sourceRun));
191 }
192 }),
193);