| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { |
| 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"; |
| 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | |
| 18 | import * as ThreadForkService from "./ThreadForkService.ts"; |
| 19 | |
| 20 | const sourceThreadId = ThreadId.make("thread:fork-snoozed-source"); |
| 21 | const targetThreadId = ThreadId.make("thread:fork-awake-target"); |
| 22 | const sourceRunId = RunId.make("run:fork-snoozed-source"); |
| 23 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| 24 | const modelSelection = { |
| 25 | instanceId: providerInstanceId, |
| 26 | model: "gpt-5.4", |
| 27 | } satisfies ModelSelection; |
| 28 | const sourceCreatedAt = DateTime.makeUnsafe("2026-07-24T09:00:00.000Z"); |
| 29 | const snoozedAt = DateTime.makeUnsafe("2026-07-24T09:05:00.000Z"); |
| 30 | const snoozedUntil = DateTime.makeUnsafe("2026-07-25T09:00:00.000Z"); |
| 31 | const forkCreatedAt = DateTime.makeUnsafe("2026-07-24T09:10:00.000Z"); |
| 32 | |
| 33 | function 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 | |
| 65 | function 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 | |
| 86 | function 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 | |
| 109 | const 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 | |
| 129 | it("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 | |
| 142 | it.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 | |
| 171 | it.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 | |
| 182 | it.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 | ); |