1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 ContextTransferId,
5 EnvironmentId,
6 MessageId,
7 type ModelSelection,
8 type OrchestrationV2AppThread,
9 type OrchestrationV2Run,
10 type OrchestrationV2ThreadProjection,
11 ProjectId,
12 ProviderInstanceId,
13 ProviderThreadId,
14 RunId,
15 ThreadId,
16 type ThreadPullRequestLink,
17} from "@t3tools/contracts";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20
21import * as ThreadForkService from "./ThreadForkService.ts";
22
23const sourceThreadId = ThreadId.make("thread:fork-snoozed-source");
24const targetThreadId = ThreadId.make("thread:fork-awake-target");
25const sourceRunId = RunId.make("run:fork-snoozed-source");
26const providerInstanceId = ProviderInstanceId.make("codex");
27const modelSelection = {
28 instanceId: providerInstanceId,
29 model: "gpt-5.4",
30} satisfies ModelSelection;
31const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z");
32const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z");
33const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z");
34const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z");
35
36function makeSourceThread(): OrchestrationV2AppThread {
37 return {
38 createdBy: "user",
39 creationSource: "web",
40 id: sourceThreadId,
41 projectId: ProjectId.make("project:fork-snooze"),
42 title: "Snoozed source",
43 providerInstanceId,
44 modelSelection,
45 runtimeMode: "full-access",
46 interactionMode: "plan",
47 branch: "feature/source",
48 worktreePath: "/tmp/source-worktree",
49 activeProviderThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
50 lineage: {
51 parentThreadId: null,
52 relationshipToParent: null,
53 rootThreadId: sourceThreadId,
54 },
55 forkedFrom: null,
56 createdAt: sourceCreatedAt,
57 updatedAt: snoozedAt,
58 archivedAt: null,
59 settledOverride: null,
60 settledAt: null,
61 lastVisitedAt: null,
62 snoozedUntil,
63 snoozedAt,
64 deletedAt: null,
65 };
66}
67
68function makeSourceRun(status: OrchestrationV2Run["status"]): OrchestrationV2Run {
69 return {
70 id: sourceRunId,
71 threadId: sourceThreadId,
72 ordinal: 1,
73 providerInstanceId,
74 modelSelection,
75 providerThreadId: ProviderThreadId.make("provider-thread:fork-snoozed-source"),
76 userMessageId: MessageId.make("message:fork-snoozed-source"),
77 rootNodeId: null,
78 activeAttemptId: null,
79 status,
80 queuePosition: null,
81 requestedAt: sourceCreatedAt,
82 startedAt: sourceCreatedAt,
83 completedAt: snoozedAt,
84 checkpointId: null,
85 contextHandoffId: null,
86 };
87}
88
89function makeSourceProjection(sourceRun: OrchestrationV2Run): OrchestrationV2ThreadProjection {
90 return {
91 thread: makeSourceThread(),
92 runs: [sourceRun],
93 attempts: [],
94 nodes: [],
95 subagents: [],
96 providerSessions: [],
97 providerThreads: [],
98 providerTurns: [],
99 runtimeRequests: [],
100 messages: [],
101 plans: [],
102 turnItems: [],
103 checkpointScopes: [],
104 checkpoints: [],
105 contextHandoffs: [],
106 contextTransfers: [],
107 visibleTurnItems: [],
108 updatedAt: snoozedAt,
109 };
110}
111
112const planFork = (
113 sourceRun: OrchestrationV2Run,
114 sourceProjection = makeSourceProjection(sourceRun),
115 options: { readonly sideChat?: boolean } = {},
116) =>
117 Effect.gen(function* () {
118 const service = yield* ThreadForkService.ThreadForkServiceV2;
119 return yield* service.plan({
120 sourceProjection,
121 sourceRun,
122 sourceProviderThread: undefined,
123 canonicalSourcePoint: {
124 threadId: sourceThreadId,
125 runId: sourceRunId,
126 },
127 transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"),
128 targetThreadId,
129 title: "Awake fork",
130 ...options,
131 createdBy: "user",
132 creationSource: "mobile",
133 createdAt: forkCreatedAt,
134 });
135 }).pipe(Effect.provide(ThreadForkService.layer));
136
137it.effect("resets a fork's pin, snooze, settle and delegation state but keeps its origin", () =>
138 Effect.gen(function* () {
139 const linkOrigin = { sessionId: "mcp-session:fork-source", label: "Linked laptop" };
140 const sourceThread: OrchestrationV2AppThread = {
141 ...makeSourceThread(),
142 lastSnoozeWakeAt: snoozedAt,
143 pinnedAt: snoozedAt,
144 pinOrderKey: "a0",
145 activeOrderKey: "a1",
146 autoSettleDisabledAt: snoozedAt,
147 delegatedFrom: {
148 environmentId: EnvironmentId.make("linked-environment"),
149 threadId: ThreadId.make("linked-parent"),
150 title: "Linked parent",
151 },
152 historyOrigin: "v1_import",
153 linkOrigin,
154 tags: ["private"],
155 };
156 const sourceRun = makeSourceRun("completed");
157 const result = yield* planFork(sourceRun, {
158 ...makeSourceProjection(sourceRun),
159 thread: sourceThread,
160 });
161
162 assert.isNull(result.targetThread.snoozedUntil);
163 assert.isNull(result.targetThread.snoozedAt);
164 assert.isNull(result.targetThread.lastSnoozeWakeAt);
165 assert.isNull(result.targetThread.pinnedAt);
166 assert.isNull(result.targetThread.pinOrderKey);
167 assert.isNull(result.targetThread.activeOrderKey);
168 assert.isNull(result.targetThread.autoSettleDisabledAt);
169 assert.isUndefined(result.targetThread.delegatedFrom);
170 assert.equal(result.targetThread.historyOrigin, "v1_import");
171 assert.deepEqual(result.targetThread.linkOrigin, linkOrigin);
172 assert.deepEqual(result.targetThread.tags, ["private"]);
173 assert.equal(result.targetThread.projectId, sourceThread.projectId);
174 assert.equal(result.targetThread.providerInstanceId, sourceThread.providerInstanceId);
175 assert.deepEqual(result.targetThread.modelSelection, sourceThread.modelSelection);
176 assert.equal(result.targetThread.runtimeMode, sourceThread.runtimeMode);
177 assert.equal(result.targetThread.interactionMode, sourceThread.interactionMode);
178 assert.equal(result.targetThread.branch, sourceThread.branch);
179 assert.equal(result.targetThread.worktreePath, sourceThread.worktreePath);
180 assert.isNull(result.targetThread.activeProviderThreadId);
181 assert.deepEqual(result.targetThread.lineage, {
182 parentThreadId: sourceThreadId,
183 relationshipToParent: "fork",
184 rootThreadId: sourceThreadId,
185 });
186 assert.deepEqual(result.targetThread.forkedFrom, {
187 type: "run",
188 threadId: sourceThreadId,
189 runId: sourceRunId,
190 });
191 }),
192);
193
194it.effect("starts a fork without the source thread's pending title regeneration", () =>
195 Effect.gen(function* () {
196 const sourceRun = makeSourceRun("completed");
197 const titleRegeneration = {
198 requestId: CommandId.make("command:fork-source-title"),
199 startedAt: snoozedAt,
200 };
201 const sourceProjection = {
202 ...makeSourceProjection(sourceRun),
203 thread: { ...makeSourceThread(), titleRegeneration },
204 };
205 const result = yield* planFork(sourceRun, sourceProjection);
206
207 assert.isNull(result.targetThread.titleRegeneration);
208 assert.deepEqual(sourceProjection.thread.titleRegeneration, titleRegeneration);
209 }),
210);
211
212it.effect("preserves pull request context without inheriting any watches", () =>
213 Effect.gen(function* () {
214 const sourceRun = makeSourceRun("completed");
215 const watch = {
216 startedAt: DateTime.formatIso(sourceCreatedAt),
217 headSha: "source-head",
218 failedChecks: ["lint"],
219 passed: false,
220 passedChecks: [],
221 remarksThrough: DateTime.formatIso(snoozedAt),
222 remarkIds: ["review-1"],
223 conflicting: true,
224 wakes: 2,
225 };
226 const links = (["agent", "stack", "manual", "stack-dismissed"] as const).map(
227 (source, index) =>
228 ({
229 host: "github.com",
230 repository: "example/repo",
231 number: index + 1,
232 url: `https://github.com/example/repo/pull/${index + 1}`,
233 source,
234 linkedAt: DateTime.formatIso(sourceCreatedAt),
235 snapshot: {
236 state: "open",
237 title: `Pull request ${index + 1}`,
238 headBranch: `feature-${index + 1}`,
239 baseBranch: "main",
240 isDraft: false,
241 updatedAt: null,
242 syncedAt: DateTime.formatIso(snoozedAt),
243 },
244 stack: {
245 kind: "native",
246 id: "stack-1",
247 number: 1,
248 url: "https://github.com/example/repo/stack/1",
249 base: "main",
250 layers: [{ number: 1, headBranch: "feature-1", state: "open" }],
251 },
252 }) satisfies ThreadPullRequestLink,
253 );
254 const watchedLinks: ReadonlyArray<ThreadPullRequestLink> = links.map((link, index) =>
255 index < 2 ? { ...link, watch } : link,
256 );
257 const legacyLink = {
258 projectId: makeSourceThread().projectId,
259 repository: "example/repo",
260 number: 1,
261 url: links[0]!.url,
262 };
263 const sourceProjection = {
264 ...makeSourceProjection(sourceRun),
265 thread: {
266 ...makeSourceThread(),
267 pullRequests: watchedLinks,
268 linkedPullRequest: legacyLink,
269 branchPullRequest: legacyLink,
270 },
271 };
272 const result = yield* planFork(sourceRun, sourceProjection);
273
274 assert.deepEqual(result.targetThread.pullRequests, links);
275 assert.deepEqual(result.targetThread.linkedPullRequest, legacyLink);
276 assert.deepEqual(result.targetThread.branchPullRequest, legacyLink);
277 assert.deepEqual(sourceProjection.thread.pullRequests, watchedLinks);
278 assert.deepEqual(sourceProjection.thread.pullRequests[0]?.watch, watch);
279 assert.deepEqual(sourceProjection.thread.pullRequests[1]?.watch, watch);
280 }),
281);
282
283it.effect("rejects in-progress and rolled-back fork sources", () =>
284 Effect.gen(function* () {
285 for (const status of ["running", "rolled_back"] as const) {
286 const sourceRun = makeSourceRun(status);
287 const error = yield* planFork(sourceRun).pipe(Effect.flip);
288 assert.equal(error._tag, "ThreadForkPlanError");
289 assert.equal(error.sourceThreadId, sourceThreadId);
290 assert.equal(error.targetThreadId, targetThreadId);
291 assert.equal(error.cause, ThreadForkService.forkableSourceRunStatusError(sourceRun));
292 }
293 }),
294);
295
296it.effect("marks only requested forks as side chats and inherits parent permissions", () =>
297 Effect.gen(function* () {
298 const sourceRun = makeSourceRun("completed");
299 const sideChat = yield* planFork(sourceRun, makeSourceProjection(sourceRun), {
300 sideChat: true,
301 });
302 assert.isTrue(sideChat.targetThread.lineage.sideChat);
303 assert.equal(sideChat.targetThread.runtimeMode, "full-access");
304 for (const runtimeMode of [
305 "approval-required",
306 "auto-accept-edits",
307 "auto",
308 "full-access",
309 ] as const) {
310 const source = makeSourceProjection(sourceRun);
311 const inherited = yield* planFork(
312 sourceRun,
313 {
314 ...source,
315 thread: { ...source.thread, runtimeMode },
316 },
317 { sideChat: true },
318 );
319 assert.equal(inherited.targetThread.runtimeMode, runtimeMode);
320 }
321
322 const sideChatSource = makeSourceProjection(sourceRun);
323 const sideChatProjection = {
324 ...sideChatSource,
325 thread: {
326 ...sideChatSource.thread,
327 lineage: { ...sideChatSource.thread.lineage, sideChat: true },
328 },
329 };
330 const forkOfSideChat = yield* planFork(sourceRun, sideChatProjection);
331 assert.isUndefined(forkOfSideChat.targetThread.lineage.sideChat);
332 assert.equal(forkOfSideChat.targetThread.runtimeMode, "full-access");
333
334 const nested = yield* planFork(sourceRun, sideChatProjection, { sideChat: true }).pipe(
335 Effect.flip,
336 );
337 assert.equal(nested.cause, "A side chat cannot start another side chat.");
338
339 const subagentSource = makeSourceProjection(sourceRun);
340 const fromSubagent = yield* planFork(
341 sourceRun,
342 {
343 ...subagentSource,
344 thread: {
345 ...subagentSource.thread,
346 lineage: { ...subagentSource.thread.lineage, relationshipToParent: "subagent" },
347 },
348 },
349 { sideChat: true },
350 ).pipe(Effect.flip);
351 assert.equal(fromSubagent.cause, "A subagent cannot start a side chat.");
352
353 const delegatedSource = makeSourceProjection(sourceRun);
354 const fromDelegated = yield* planFork(
355 sourceRun,
356 {
357 ...delegatedSource,
358 thread: {
359 ...delegatedSource.thread,
360 delegatedFrom: {
361 environmentId: EnvironmentId.make("linked-environment"),
362 threadId: ThreadId.make("linked-parent"),
363 title: "Linked parent",
364 },
365 },
366 },
367 { sideChat: true },
368 ).pipe(Effect.flip);
369 assert.equal(fromDelegated.cause, "A subagent cannot start a side chat.");
370 }),
371);