1import { describe, expect, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 CommandId,
5 EnvironmentId,
6 type OrchestrationV2ThreadProjection,
7 type Project,
8 ProjectId,
9 ProviderInstanceId,
10 ThreadId,
11 WorktreeMcpHandoffInput,
12} from "@t3tools/contracts";
13import * as Cause from "effect/Cause";
14import * as Deferred from "effect/Deferred";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22
23import * as GitWorkflowService from "../git/GitWorkflowService.ts";
24import {
25 OrchestratorDispatchError,
26 OrchestratorProjectionError,
27} from "../orchestration-v2/Orchestrator.ts";
28import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
29import * as ProjectService from "../project/ProjectService.ts";
30import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
31import * as ServerSettings from "../serverSettings.ts";
32import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
33import type * as McpInvocationContext from "./McpInvocationContext.ts";
34import * as WorktreeMcpService from "./WorktreeMcpService.ts";
35
36const environmentId = EnvironmentId.make("environment-worktree-test");
37const threadId = ThreadId.make("thread-worktree-test");
38const projectId = ProjectId.make("project-worktree-test");
39const workspaceRoot = "/repo/project";
40
41const makeScope = (
42 capabilities: ReadonlySet<McpInvocationContext.McpCapability>,
43): McpInvocationContext.McpInvocationScope => ({
44 environmentId,
45 requestNamespace: "provider-session-worktree-test",
46 thread: {
47 threadId,
48 providerSessionId: "provider-session-worktree-test",
49 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
50 },
51 client: undefined,
52 capabilities,
53 issuedAt: 1,
54});
55
56interface ThreadFixture {
57 readonly branch?: string | null;
58 readonly worktreePath?: string | null;
59 readonly archivedAt?: string | null;
60 readonly deletedAt?: string | null;
61}
62
63const makeProjection = (overrides: ThreadFixture = {}): OrchestrationV2ThreadProjection =>
64 ({
65 thread: {
66 id: threadId,
67 projectId,
68 title: "Worktree test thread",
69 branch: null,
70 worktreePath: null,
71 archivedAt: null,
72 deletedAt: null,
73 ...overrides,
74 },
75 }) as OrchestrationV2ThreadProjection;
76
77const project: Project = {
78 id: projectId,
79 title: "Worktree test project",
80 workspaceRoot,
81 defaultModelSelection: null,
82 scripts: [],
83 createdAt: "2026-01-01T00:00:00.000Z",
84 updatedAt: "2026-01-01T00:00:00.000Z",
85 deletedAt: null,
86} as Project;
87
88interface HarnessOptions {
89 readonly thread?: ThreadFixture | null;
90 readonly threadReadError?: "projection" | "dispatch";
91 readonly capabilities?: ReadonlySet<McpInvocationContext.McpCapability>;
92 readonly currentBranch?: string | null;
93 readonly notARepo?: boolean;
94 readonly newWorktreesStartFromOrigin?: boolean;
95 readonly setupScript?: "started" | "no-script" | "fails" | "dies";
96 readonly dispatchFails?: boolean;
97 readonly dispatchDies?: boolean;
98 readonly dispatchInterrupts?: boolean;
99 readonly dispatchGate?: Effect.Effect<void>;
100 readonly threadAttachedOnRecheck?: boolean;
101 readonly threadArchivedOnRecheck?: boolean;
102 readonly threadReadFailsOnRecheck?: boolean;
103 readonly continuation?: "queued" | "fails" | "dies";
104 readonly projectMissing?: boolean;
105 readonly projectReadFails?: boolean;
106 readonly existingBranchWorktreePath?: string | null;
107 readonly pathSemantics?: "win32" | "posix";
108 readonly createWorktreeFails?: boolean;
109 readonly fetchRemoteFails?: boolean;
110 readonly resolveRemoteFails?: boolean;
111 readonly removeWorktreeFails?: boolean;
112 readonly deleteLocalBranchFails?: boolean;
113 readonly createWorktreeGate?: Effect.Effect<void>;
114}
115
116const makeHarness = (options: HarnessOptions = {}) => {
117 const thread = options.thread === undefined ? {} : options.thread;
118 const scope = makeScope(options.capabilities ?? new Set(["preview", "worktree"]));
119 const dispatch = vi.fn((_: unknown) =>
120 (options.dispatchGate ?? Effect.void).pipe(
121 Effect.andThen(
122 options.dispatchInterrupts
123 ? (Effect.failCause(Cause.interrupt()) as never)
124 : options.dispatchDies
125 ? Effect.die(new Error("dispatch defect"))
126 : options.dispatchFails
127 ? (Effect.fail("simulated dispatch failure") as never)
128 : Effect.succeed({ sequence: 1, storedEvents: [] }),
129 ),
130 ),
131 );
132 const listLocalBranchNames = vi.fn((_: string) =>
133 Effect.succeed(
134 options.existingBranchWorktreePath === undefined
135 ? ["dev"]
136 : ["dev", "feature/taken", "feature/taken-idle"],
137 ),
138 );
139 const getThreadRecords = vi.fn((id: ThreadId) => {
140 if (options.threadReadError === "dispatch") {
141 return Effect.fail(
142 new OrchestratorDispatchError({
143 commandId: CommandId.make("command:test:read"),
144 commandType: "thread.metadata.update",
145 }),
146 ) as never;
147 }
148 if (options.threadReadFailsOnRecheck === true && getThreadRecords.mock.calls.length > 1) {
149 return Effect.fail(
150 new OrchestratorDispatchError({
151 commandId: CommandId.make("command:test:recheck"),
152 commandType: "thread.metadata.update",
153 }),
154 ) as never;
155 }
156 if (
157 options.threadAttachedOnRecheck === true &&
158 getThreadRecords.mock.calls.length > 1 &&
159 thread !== null
160 ) {
161 return Effect.succeed(
162 makeProjection({ ...thread, worktreePath: "/worktrees/project/raced" }),
163 );
164 }
165 if (
166 options.threadArchivedOnRecheck === true &&
167 getThreadRecords.mock.calls.length > 1 &&
168 thread !== null
169 ) {
170 return Effect.succeed(makeProjection({ ...thread, archivedAt: "2026-01-02T00:00:00.000Z" }));
171 }
172 return id === threadId && thread !== null
173 ? Effect.succeed(makeProjection(thread))
174 : Effect.fail(new OrchestratorProjectionError({ threadId: id }));
175 });
176 const sendToThread = vi.fn((_: unknown) => {
177 switch (options.continuation ?? "queued") {
178 case "fails":
179 return Effect.fail(
180 new ThreadManagementService.ThreadManagementThreadArchivedError({
181 threadId,
182 }),
183 );
184 case "dies":
185 return Effect.die(new Error("send defect"));
186 default:
187 return Effect.succeed({
188 delivery: "queued",
189 } as ThreadManagementService.ThreadManagementSendResult);
190 }
191 });
192 const getById = vi.fn((id: ProjectId) =>
193 options.projectReadFails
194 ? (Effect.fail("simulated project read failure") as never)
195 : Effect.succeed(
196 id === projectId && options.projectMissing !== true
197 ? Option.some(project)
198 : Option.none(),
199 ),
200 );
201 const removeWorktree = vi.fn((_: unknown) =>
202 options.removeWorktreeFails
203 ? (Effect.fail("simulated worktree removal failure") as never)
204 : Effect.void,
205 );
206 const deleteLocalBranch = vi.fn((_: unknown) =>
207 options.deleteLocalBranchFails
208 ? (Effect.fail("simulated local branch deletion failure") as never)
209 : Effect.void,
210 );
211 const fetchRemote = vi.fn((_: unknown) =>
212 options.fetchRemoteFails ? (Effect.fail("simulated fetch failure") as never) : Effect.void,
213 );
214 const resolveRemoteTrackingCommit = vi.fn((_: unknown) =>
215 options.resolveRemoteFails
216 ? (Effect.fail("simulated remote resolve failure") as never)
217 : Effect.succeed({ commitSha: "abc123", remoteRefName: "origin/dev" }),
218 );
219 const createWorktree = vi.fn(
220 (input: { readonly newRefName?: string | undefined; readonly path: string | null }) =>
221 options.createWorktreeFails
222 ? (Effect.fail("simulated worktree creation failure") as never)
223 : (options.createWorktreeGate ?? Effect.void).pipe(
224 Effect.andThen(
225 Effect.succeed({
226 worktree: {
227 path: input.path ?? `/worktrees/project/${input.newRefName}`,
228 refName: input.newRefName ?? "detached",
229 },
230 }),
231 ),
232 ),
233 );
234 const listRefs = vi.fn((input: { readonly query?: string | undefined }) =>
235 Effect.succeed({
236 refs:
237 options.existingBranchWorktreePath === undefined
238 ? []
239 : [
240 {
241 name: input.query ?? "",
242 current: false,
243 isDefault: false,
244 worktreePath: options.existingBranchWorktreePath,
245 },
246 ],
247 isRepo: true,
248 hasPrimaryRemote: true,
249 nextCursor: null,
250 totalCount: options.existingBranchWorktreePath === undefined ? 0 : 1,
251 }),
252 );
253 const localStatus = vi.fn((_: unknown) =>
254 Effect.succeed({
255 isRepo: options.notARepo !== true,
256 hasPrimaryRemote: true,
257 isDefaultRef: false,
258 refName: options.currentBranch === undefined ? "dev" : options.currentBranch,
259 hasWorkingTreeChanges: false,
260 workingTree: { files: [], insertions: 0, deletions: 0 },
261 }),
262 );
263 const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub"));
264 const runForThread = vi.fn((input: { readonly worktreePath: string }) => {
265 switch (options.setupScript ?? "started") {
266 case "no-script":
267 return Effect.succeed({ status: "no-script" } as const);
268 case "dies":
269 return Effect.die(new Error("setup runner defect"));
270 case "fails":
271 return Effect.fail(
272 new ProjectSetupScriptRunner.ProjectSetupScriptProjectNotFoundError({
273 threadId,
274 worktreePath: input.worktreePath,
275 }),
276 );
277 default:
278 return Effect.succeed({
279 status: "started",
280 async: false,
281 scriptId: "setup",
282 scriptName: "Setup",
283 scriptCommand: "vp install",
284 terminalId: "setup-terminal",
285 cwd: input.worktreePath,
286 } as const);
287 }
288 });
289
290 // Optional deterministic Path semantics: providing this BEFORE the general
291 // mocks means the service resolves Path here rather than from NodeServices,
292 // so absolute-path validation is testable independently of the host OS. The
293 // service only calls isAbsolute; the minimal per-platform semantics are
294 // inlined so the test does not depend on the host's path module.
295 const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value);
296 const posixIsAbsolute = (value: string) => value.startsWith("/");
297 const layerService =
298 options.pathSemantics === undefined
299 ? WorktreeMcpService.layer
300 : WorktreeMcpService.layer.pipe(
301 Layer.provide(
302 Layer.succeed(Path.Path, {
303 isAbsolute: options.pathSemantics === "win32" ? win32IsAbsolute : posixIsAbsolute,
304 } as unknown as Path.Path),
305 ),
306 );
307 const layer = layerService.pipe(
308 Layer.provide(
309 Layer.mergeAll(
310 Layer.mock(ThreadManagementService.ThreadManagementService)({
311 dispatch,
312 getThreadRecords,
313 sendToThread,
314 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
315 Layer.mock(ProjectService.ProjectService)({
316 getById,
317 } satisfies Partial<ProjectService.ProjectService["Service"]>),
318 ServerSettings.layerTest({
319 newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false,
320 }),
321 Layer.mock(GitWorkflowService.GitWorkflowService)({
322 listRefs,
323 listLocalBranchNames,
324 localStatus,
325 fetchRemote,
326 resolveRemoteTrackingCommit,
327 createWorktree,
328 removeWorktree,
329 deleteLocalBranch,
330 } satisfies Partial<GitWorkflowService.GitWorkflowService["Service"]>),
331 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
332 runForThread,
333 } satisfies Partial<ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]>),
334 Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({
335 refreshStatus,
336 } satisfies Partial<VcsStatusBroadcaster.VcsStatusBroadcaster["Service"]>),
337 NodeServices.layer,
338 ),
339 ),
340 );
341
342 return {
343 layer,
344 scope,
345 dispatch,
346 sendToThread,
347 fetchRemote,
348 resolveRemoteTrackingCommit,
349 createWorktree,
350 removeWorktree,
351 deleteLocalBranch,
352 localStatus,
353 runForThread,
354 };
355};
356
357const expectTypedFailure = (exit: Exit.Exit<unknown, unknown>, expected: object): void => {
358 if (!Exit.isFailure(exit)) {
359 expect.fail(`Expected a failure exit, got: ${JSON.stringify(exit)}`);
360 }
361 const reason = exit.cause.reasons[0];
362 if (reason?._tag !== "Fail") {
363 expect.fail(`Expected a typed Fail cause, got: ${reason?._tag ?? "no reason"}`);
364 }
365 expect(reason.error).toMatchObject(expected);
366};
367
368// Resolves the service once from the harness layer; used by tests that must
369// make several calls against the SAME instance (the in-flight guard lives in
370// the layer's closure, so a fresh layer per call would never see it).
371const resolveService = (harness: ReturnType<typeof makeHarness>) =>
372 Effect.gen(function* () {
373 return yield* WorktreeMcpService.WorktreeMcpService;
374 }).pipe(Effect.provide(harness.layer));
375
376const runHandoff = (
377 harness: ReturnType<typeof makeHarness>,
378 input: Parameters<WorktreeMcpService.WorktreeMcpService["Service"]["handoff"]>[1],
379) =>
380 Effect.gen(function* () {
381 const service = yield* WorktreeMcpService.WorktreeMcpService;
382 return yield* service.handoff(harness.scope, input);
383 }).pipe(Effect.provide(harness.layer));
384
385const runStatus = (harness: ReturnType<typeof makeHarness>) =>
386 Effect.gen(function* () {
387 const service = yield* WorktreeMcpService.WorktreeMcpService;
388 return yield* service.status(harness.scope);
389 }).pipe(Effect.provide(harness.layer));
390
391describe("t3_worktree_handoff", () => {
392 it.effect("creates a worktree from the current branch and re-points the thread", () => {
393 const harness = makeHarness();
394 return Effect.gen(function* () {
395 const result = yield* runHandoff(harness, { branch: "feature/handoff" });
396
397 expect(result.branch).toBe("feature/handoff");
398 expect(result.baseRef).toBe("dev");
399 expect(result.startedFromOrigin).toBe(false);
400 expect(result.worktreePath).toBe("/worktrees/project/feature/handoff");
401 expect(result.setupScript).toMatchObject({
402 status: "started",
403 scriptName: "Setup",
404 });
405
406 expect(harness.fetchRemote).not.toHaveBeenCalled();
407 expect(harness.createWorktree).toHaveBeenCalledWith({
408 cwd: workspaceRoot,
409 refName: "dev",
410 newRefName: "feature/handoff",
411 baseRefName: "dev",
412 path: null,
413 });
414 expect(harness.dispatch).toHaveBeenCalledWith(
415 expect.objectContaining({
416 type: "thread.metadata.update",
417 threadId,
418 branch: "feature/handoff",
419 worktreePath: "/worktrees/project/feature/handoff",
420 expectedWorktreePath: null,
421 }),
422 );
423 expect(harness.runForThread).toHaveBeenCalledWith({
424 threadId,
425 projectId,
426 projectCwd: workspaceRoot,
427 worktreePath: "/worktrees/project/feature/handoff",
428 project: { id: projectId, workspaceRoot, scripts: [] },
429 });
430 });
431 });
432
433 it.effect("skips the continuation when no continuationPrompt is given", () => {
434 const harness = makeHarness();
435 return Effect.gen(function* () {
436 const result = yield* runHandoff(harness, { branch: "feature/no-continuation" });
437 expect(result.continuation).toEqual({ status: "skipped" });
438 expect(harness.sendToThread).not.toHaveBeenCalled();
439 });
440 });
441
442 it.effect("queues the continuation prompt as the thread's next message", () => {
443 const harness = makeHarness();
444 return Effect.gen(function* () {
445 const result = yield* runHandoff(harness, {
446 branch: "feature/continue",
447 continuationPrompt: "Keep fixing the login bug in the new worktree.",
448 });
449
450 expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" });
451 expect(harness.sendToThread).toHaveBeenCalledWith(
452 expect.objectContaining({
453 projectId,
454 threadId,
455 text: "Keep fixing the login bug in the new worktree.",
456 mode: "queue",
457 createdBy: "agent",
458 creationSource: "mcp",
459 }),
460 );
461 // The continuation must be durably queued before anything slower runs.
462 expect(harness.sendToThread.mock.invocationCallOrder[0]).toBeLessThan(
463 harness.runForThread.mock.invocationCallOrder[0]!,
464 );
465 });
466 });
467
468 it.effect("reports a continuation failure without failing the handoff", () => {
469 const harness = makeHarness({ continuation: "fails" });
470 return Effect.gen(function* () {
471 const result = yield* runHandoff(harness, {
472 branch: "feature/continue-fails",
473 continuationPrompt: "Keep going.",
474 });
475 expect(result.continuation).toMatchObject({ status: "failed" });
476 expect(result.worktreePath).toBe("/worktrees/project/feature/continue-fails");
477 expect(harness.dispatch).toHaveBeenCalled();
478 });
479 });
480
481 it.effect("reports a continuation defect without failing the handoff", () => {
482 const harness = makeHarness({ continuation: "dies" });
483 return Effect.gen(function* () {
484 const result = yield* runHandoff(harness, {
485 branch: "feature/continue-dies",
486 continuationPrompt: "Keep going.",
487 });
488 expect(result.continuation).toEqual({ status: "failed", detail: "send defect" });
489 expect(result.setupScript).toMatchObject({ status: "started" });
490 });
491 });
492
493 it.effect("does not queue a continuation when the thread update fails", () => {
494 const harness = makeHarness({ dispatchFails: true });
495 return Effect.gen(function* () {
496 const exit = yield* Effect.exit(
497 runHandoff(harness, {
498 branch: "feature/dispatch-fails-continue",
499 continuationPrompt: "Keep going.",
500 }),
501 );
502 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
503 expect(harness.sendToThread).not.toHaveBeenCalled();
504 });
505 });
506
507 it.effect("starts from origin and honors explicit baseRef and path", () => {
508 const harness = makeHarness();
509 return Effect.gen(function* () {
510 const result = yield* runHandoff(harness, {
511 branch: "feature/from-origin",
512 baseRef: "dev",
513 startFromOrigin: true,
514 path: "/custom/worktree/location",
515 runSetupScript: false,
516 });
517
518 expect(harness.fetchRemote).toHaveBeenCalledWith({
519 cwd: workspaceRoot,
520 remoteName: "origin",
521 });
522 expect(harness.resolveRemoteTrackingCommit).toHaveBeenCalledWith({
523 cwd: workspaceRoot,
524 refName: "dev",
525 fallbackRemoteName: "origin",
526 });
527 expect(harness.createWorktree).toHaveBeenCalledWith({
528 cwd: workspaceRoot,
529 refName: "abc123",
530 newRefName: "feature/from-origin",
531 baseRefName: "dev",
532 path: "/custom/worktree/location",
533 });
534 // localStatus is always consulted now (repo pre-check), but its branch
535 // must not override the explicit baseRef.
536 expect(harness.localStatus).toHaveBeenCalled();
537 expect(harness.runForThread).not.toHaveBeenCalled();
538 expect(result.worktreePath).toBe("/custom/worktree/location");
539 expect(result.startedFromOrigin).toBe(true);
540 expect(result.setupScript).toEqual({ status: "skipped" });
541 });
542 });
543
544 it.effect("uses the server setting for startFromOrigin when unspecified", () => {
545 const harness = makeHarness({ newWorktreesStartFromOrigin: true });
546 return Effect.gen(function* () {
547 const result = yield* runHandoff(harness, { branch: "feature/settings-origin" });
548 expect(result.startedFromOrigin).toBe(true);
549 expect(harness.fetchRemote).toHaveBeenCalled();
550 });
551 });
552
553 it.effect("fails when the thread is already attached to a worktree", () => {
554 const harness = makeHarness({
555 thread: { branch: "feature/existing", worktreePath: "/worktrees/project/existing" },
556 });
557 return Effect.gen(function* () {
558 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/second" }));
559 expectTypedFailure(exit, {
560 _tag: "WorktreeMcpFailure",
561 code: "already_in_worktree",
562 });
563 expect(harness.createWorktree).not.toHaveBeenCalled();
564 });
565 });
566
567 it.effect("fails when the thread does not exist", () => {
568 const harness = makeHarness({ thread: null });
569 return Effect.gen(function* () {
570 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/missing" }));
571 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" });
572 expect(harness.createWorktree).not.toHaveBeenCalled();
573 });
574 });
575
576 it.effect("treats a soft-deleted thread as not found", () => {
577 const harness = makeHarness({ thread: { deletedAt: "2026-01-02T00:00:00.000Z" } });
578 return Effect.gen(function* () {
579 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/deleted" }));
580 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" });
581 expect(harness.createWorktree).not.toHaveBeenCalled();
582 });
583 });
584
585 it.effect("maps a non-projection orchestrator error to operation_failed", () => {
586 const harness = makeHarness({ threadReadError: "dispatch" });
587 return Effect.gen(function* () {
588 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/read-error" }));
589 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
590 expect(harness.createWorktree).not.toHaveBeenCalled();
591 });
592 });
593
594 it.effect("fails when the project does not exist", () => {
595 const harness = makeHarness({ projectMissing: true });
596 return Effect.gen(function* () {
597 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-project" }));
598 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "project_not_found" });
599 expect(harness.createWorktree).not.toHaveBeenCalled();
600 });
601 });
602
603 it.effect("maps a project read error to operation_failed", () => {
604 const harness = makeHarness({ projectReadFails: true });
605 return Effect.gen(function* () {
606 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/project-error" }));
607 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
608 expect(harness.createWorktree).not.toHaveBeenCalled();
609 });
610 });
611
612 it.effect("fails when the project workspace is not a git repository", () => {
613 const harness = makeHarness({ notARepo: true });
614 return Effect.gen(function* () {
615 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-repo" }));
616 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
617 expect(harness.createWorktree).not.toHaveBeenCalled();
618 });
619 });
620
621 it.effect("rejects an existing branch with an actionable error naming its checkout", () => {
622 const harness = makeHarness({ existingBranchWorktreePath: "/elsewhere/checkout" });
623 return Effect.gen(function* () {
624 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/taken" }));
625 if (!Exit.isFailure(exit)) {
626 return expect.fail("expected a failure exit");
627 }
628 const reason = exit.cause.reasons[0];
629 expect(reason?._tag).toBe("Fail");
630 const error = (reason as { readonly error: { code: string; message: string } }).error;
631 expect(error.code).toBe("invalid_request");
632 expect(error.message).toContain("feature/taken");
633 expect(error.message).toContain("already exists");
634 expect(error.message).toContain("/elsewhere/checkout");
635 expect(harness.createWorktree).not.toHaveBeenCalled();
636 expect(harness.fetchRemote).not.toHaveBeenCalled();
637 });
638 });
639
640 it.effect("rejects an existing branch that is not checked out anywhere", () => {
641 const harness = makeHarness({ existingBranchWorktreePath: null });
642 return Effect.gen(function* () {
643 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/taken-idle" }));
644 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
645 expect(harness.createWorktree).not.toHaveBeenCalled();
646 });
647 });
648
649 it.effect("does not trip the pre-flight on similarly named branches", () => {
650 const harness = makeHarness({ existingBranchWorktreePath: null });
651 return Effect.gen(function* () {
652 // "feature/taken" exists in the mock branch list; "feature/take" does
653 // not, and a substring-based check would wrongly match it.
654 const result = yield* runHandoff(harness, { branch: "feature/take" });
655 expect(result.branch).toBe("feature/take");
656 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
657 });
658 });
659
660 it.effect("rejects a non-repository workspace even when baseRef is explicit", () => {
661 const harness = makeHarness({ notARepo: true });
662 return Effect.gen(function* () {
663 const exit = yield* Effect.exit(
664 runHandoff(harness, { branch: "feature/no-repo-baseref", baseRef: "dev" }),
665 );
666 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
667 expect(harness.createWorktree).not.toHaveBeenCalled();
668 });
669 });
670
671 it.effect("propagates an interrupted dispatch without rolling back the worktree", () => {
672 const harness = makeHarness({ dispatchInterrupts: true });
673 return Effect.gen(function* () {
674 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-int" }));
675 if (!Exit.isFailure(exit)) {
676 return expect.fail("expected a failure exit");
677 }
678 // Whether the binding committed is unknown on interruption, so the
679 // worktree must not be force-deleted and no typed failure is invented.
680 expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true);
681 expect(harness.removeWorktree).not.toHaveBeenCalled();
682 });
683 });
684
685 it.effect("maps a worktree creation failure to operation_failed", () => {
686 const harness = makeHarness({ createWorktreeFails: true });
687 return Effect.gen(function* () {
688 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/create-fails" }));
689 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
690 expect(harness.dispatch).not.toHaveBeenCalled();
691 expect(harness.removeWorktree).not.toHaveBeenCalled();
692 });
693 });
694
695 it.effect("maps an origin fetch failure to operation_failed", () => {
696 const harness = makeHarness({ fetchRemoteFails: true });
697 return Effect.gen(function* () {
698 const exit = yield* Effect.exit(
699 runHandoff(harness, { branch: "feature/fetch-fails", startFromOrigin: true }),
700 );
701 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
702 expect(harness.createWorktree).not.toHaveBeenCalled();
703 });
704 });
705
706 it.effect("maps a remote-tracking resolve failure to operation_failed", () => {
707 const harness = makeHarness({ resolveRemoteFails: true });
708 return Effect.gen(function* () {
709 const exit = yield* Effect.exit(
710 runHandoff(harness, { branch: "feature/resolve-fails", startFromOrigin: true }),
711 );
712 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
713 expect(harness.createWorktree).not.toHaveBeenCalled();
714 });
715 });
716
717 it.effect("removes the created worktree when the thread update dies with a defect", () => {
718 const harness = makeHarness({ dispatchDies: true });
719 return Effect.gen(function* () {
720 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-defect" }));
721 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
722 expect(harness.removeWorktree).toHaveBeenCalledWith({
723 cwd: workspaceRoot,
724 path: "/worktrees/project/feature/dispatch-defect",
725 force: true,
726 });
727 });
728 });
729
730 it.effect("re-checks attachment after creating the worktree and backs out on a race", () => {
731 const harness = makeHarness({ threadAttachedOnRecheck: true });
732 return Effect.gen(function* () {
733 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/raced" }));
734 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
735 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
736 // The freshly created worktree must not be left orphaned.
737 expect(harness.removeWorktree).toHaveBeenCalledWith({
738 cwd: workspaceRoot,
739 path: "/worktrees/project/feature/raced",
740 force: true,
741 });
742 expect(harness.deleteLocalBranch).toHaveBeenCalledWith({
743 cwd: workspaceRoot,
744 refName: "feature/raced",
745 force: true,
746 });
747 expect(harness.dispatch).not.toHaveBeenCalled();
748 });
749 });
750
751 it.effect("removes the created worktree when the recheck read fails", () => {
752 const harness = makeHarness({ threadReadFailsOnRecheck: true });
753 return Effect.gen(function* () {
754 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/recheck-fails" }));
755 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
756 expect(harness.removeWorktree).toHaveBeenCalledWith({
757 cwd: workspaceRoot,
758 path: "/worktrees/project/feature/recheck-fails",
759 force: true,
760 });
761 expect(harness.dispatch).not.toHaveBeenCalled();
762 });
763 });
764
765 it.effect("backs out when the thread is archived during worktree creation", () => {
766 const harness = makeHarness({ threadArchivedOnRecheck: true });
767 return Effect.gen(function* () {
768 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/archived-race" }));
769 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
770 expect(harness.dispatch).not.toHaveBeenCalled();
771 // The created worktree must not be left orphaned.
772 expect(harness.removeWorktree).toHaveBeenCalledWith({
773 cwd: workspaceRoot,
774 path: "/worktrees/project/feature/archived-race",
775 force: true,
776 });
777 });
778 });
779
780 it.effect("rejects a handoff for an archived thread", () => {
781 const harness = makeHarness({ thread: { archivedAt: "2026-01-02T00:00:00.000Z" } });
782 return Effect.gen(function* () {
783 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/archived" }));
784 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
785 expect(harness.createWorktree).not.toHaveBeenCalled();
786 });
787 });
788
789 it.effect("queues the continuation even when interrupted during the binding dispatch", () =>
790 Effect.gen(function* () {
791 const gate = yield* Deferred.make<void>();
792 const harness = makeHarness({ dispatchGate: Deferred.await(gate) });
793
794 // Interrupt arrives while the metadata dispatch is in flight; the
795 // binding-plus-continuation section must run to completion anyway so the
796 // continuation is never lost between the commit and the queue.
797 const fiber = yield* Effect.forkChild(
798 runHandoff(harness, {
799 branch: "feature/interrupted",
800 continuationPrompt: "Keep going in the worktree.",
801 }),
802 );
803 yield* Effect.yieldNow;
804 const interruption = yield* Effect.forkChild(Fiber.interrupt(fiber));
805 yield* Effect.yieldNow;
806 yield* Deferred.succeed(gate, undefined);
807 yield* Fiber.await(fiber);
808 yield* Fiber.join(interruption);
809
810 expect(harness.dispatch).toHaveBeenCalledTimes(1);
811 expect(harness.sendToThread).toHaveBeenCalledTimes(1);
812 // The setup script must also survive the pending interrupt; otherwise
813 // the continuation run starts in a worktree that was never set up.
814 expect(harness.runForThread).toHaveBeenCalledTimes(1);
815 }),
816 );
817
818 it.effect("still fails with a typed error when the rollback removal also fails", () => {
819 const harness = makeHarness({ dispatchFails: true, removeWorktreeFails: true });
820 return Effect.gen(function* () {
821 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/rollback-fails" }));
822 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
823 expect(harness.removeWorktree).toHaveBeenCalledTimes(1);
824 expect(harness.deleteLocalBranch).not.toHaveBeenCalled();
825 });
826 });
827
828 it.effect("preserves the typed failure when rollback branch deletion also fails", () => {
829 const harness = makeHarness({ dispatchFails: true, deleteLocalBranchFails: true });
830 return Effect.gen(function* () {
831 const exit = yield* Effect.exit(
832 runHandoff(harness, { branch: "feature/rollback-branch-fails" }),
833 );
834 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
835 expect(harness.removeWorktree).toHaveBeenCalledTimes(1);
836 expect(harness.deleteLocalBranch).toHaveBeenCalledWith({
837 cwd: workspaceRoot,
838 refName: "feature/rollback-branch-fails",
839 force: true,
840 });
841 });
842 });
843
844 it.effect("accepts a Windows drive path under win32 path semantics", () => {
845 const harness = makeHarness({ pathSemantics: "win32" });
846 return Effect.gen(function* () {
847 const result = yield* runHandoff(harness, {
848 branch: "feature/windows-path",
849 path: "C:\\worktrees\\custom",
850 });
851 expect(result.worktreePath).toBe("C:\\worktrees\\custom");
852 expect(harness.createWorktree).toHaveBeenCalledWith(
853 expect.objectContaining({ path: "C:\\worktrees\\custom" }),
854 );
855 });
856 });
857
858 it.effect("rejects a Windows drive path under posix path semantics", () => {
859 const harness = makeHarness({ pathSemantics: "posix" });
860 return Effect.gen(function* () {
861 // The schema-level pattern admits drive paths cross-platform; the
862 // runtime check reflects the host the worktree would be created on.
863 const exit = yield* Effect.exit(
864 runHandoff(harness, { branch: "feature/windows-path", path: "C:\\worktrees\\custom" }),
865 );
866 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
867 expect(harness.createWorktree).not.toHaveBeenCalled();
868 });
869 });
870
871 it.effect("releases the per-thread guard after a failed handoff", () => {
872 const harness = makeHarness({
873 thread: { worktreePath: "/worktrees/project/existing" },
874 });
875 return Effect.gen(function* () {
876 const service = yield* resolveService(harness);
877
878 const first = yield* Effect.exit(
879 service.handoff(harness.scope, { branch: "feature/guard-1" }),
880 );
881 expectTypedFailure(first, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
882 // A leaked guard would surface as handoff_in_progress here.
883 const second = yield* Effect.exit(
884 service.handoff(harness.scope, { branch: "feature/guard-2" }),
885 );
886 expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" });
887 });
888 });
889
890 it.effect("fails when the worktree capability is missing", () => {
891 const harness = makeHarness({ capabilities: new Set(["preview"]) });
892 return Effect.gen(function* () {
893 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-capability" }));
894 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "capability_denied" });
895 });
896 });
897
898 it.effect("serializes concurrent handoffs for the same thread", () =>
899 Effect.gen(function* () {
900 const gate = yield* Deferred.make<void>();
901 const harness = makeHarness({ createWorktreeGate: Deferred.await(gate) });
902
903 const service = yield* resolveService(harness);
904
905 // First handoff acquires the per-thread guard and blocks on the gate.
906 const first = yield* Effect.forkChild(
907 Effect.exit(service.handoff(harness.scope, { branch: "feature/race-1" })),
908 );
909 yield* Effect.yieldNow;
910
911 // Second handoff for the same thread must be refused while the first
912 // is still in flight.
913 const second = yield* Effect.exit(
914 service.handoff(harness.scope, { branch: "feature/race-2" }),
915 );
916 expectTypedFailure(second, { _tag: "WorktreeMcpFailure", code: "handoff_in_progress" });
917
918 yield* Deferred.succeed(gate, undefined);
919 const firstExit = yield* Fiber.join(first);
920 expect(Exit.isSuccess(firstExit)).toBe(true);
921 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
922 }),
923 );
924
925 it.effect("removes the created worktree when the thread update fails", () => {
926 const harness = makeHarness({ dispatchFails: true });
927 return Effect.gen(function* () {
928 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-fails" }));
929 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
930 expect(harness.createWorktree).toHaveBeenCalledTimes(1);
931 expect(harness.removeWorktree).toHaveBeenCalledWith({
932 cwd: workspaceRoot,
933 path: "/worktrees/project/feature/dispatch-fails",
934 force: true,
935 });
936 });
937 });
938
939 it.effect("rejects a relative path", () => {
940 const harness = makeHarness();
941 return Effect.gen(function* () {
942 const exit = yield* Effect.exit(
943 runHandoff(harness, { branch: "feature/relative-path", path: "worktrees/nested" }),
944 );
945 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
946 expect(harness.createWorktree).not.toHaveBeenCalled();
947 });
948 });
949
950 it.effect("fails when baseRef is omitted and HEAD is detached", () => {
951 const harness = makeHarness({ currentBranch: null });
952 return Effect.gen(function* () {
953 const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/detached" }));
954 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" });
955 });
956 });
957
958 it.effect("reports setup script failure without failing the handoff", () => {
959 const harness = makeHarness({ setupScript: "fails" });
960 return Effect.gen(function* () {
961 const result = yield* runHandoff(harness, { branch: "feature/setup-fails" });
962 expect(result.setupScript.status).toBe("failed");
963 expect(harness.dispatch).toHaveBeenCalled();
964 });
965 });
966
967 it.effect("reports a setup script defect without failing the handoff", () => {
968 const harness = makeHarness({ setupScript: "dies" });
969 return Effect.gen(function* () {
970 const result = yield* runHandoff(harness, { branch: "feature/setup-dies" });
971 expect(result.setupScript).toEqual({ status: "failed", detail: "setup runner defect" });
972 expect(harness.dispatch).toHaveBeenCalled();
973 });
974 });
975});
976
977describe("t3_worktree_status", () => {
978 it.effect("reports an unattached thread", () => {
979 const harness = makeHarness({ newWorktreesStartFromOrigin: true });
980 return Effect.gen(function* () {
981 const result = yield* runStatus(harness);
982 expect(result).toEqual({
983 attached: false,
984 worktreePath: null,
985 branch: null,
986 projectWorkspaceRoot: workspaceRoot,
987 defaultStartFromOrigin: true,
988 });
989 });
990 });
991
992 it.effect("reports an attached thread's worktree and branch", () => {
993 const harness = makeHarness({
994 thread: {
995 worktreePath: "/worktrees/project/existing",
996 branch: "feature/existing",
997 },
998 });
999 return Effect.gen(function* () {
1000 const result = yield* runStatus(harness);
1001 expect(result).toMatchObject({
1002 attached: true,
1003 worktreePath: "/worktrees/project/existing",
1004 branch: "feature/existing",
1005 defaultStartFromOrigin: false,
1006 });
1007 });
1008 });
1009
1010 it.effect("fails when the worktree capability is missing", () => {
1011 const harness = makeHarness({ capabilities: new Set(["preview"]) });
1012 return Effect.gen(function* () {
1013 const exit = yield* Effect.exit(runStatus(harness));
1014 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "capability_denied" });
1015 });
1016 });
1017
1018 it.effect("fails when the thread does not exist", () => {
1019 const harness = makeHarness({ thread: null });
1020 return Effect.gen(function* () {
1021 const exit = yield* Effect.exit(runStatus(harness));
1022 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" });
1023 });
1024 });
1025
1026 it.effect("fails when the project does not exist", () => {
1027 const harness = makeHarness({ projectMissing: true });
1028 return Effect.gen(function* () {
1029 const exit = yield* Effect.exit(runStatus(harness));
1030 expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "project_not_found" });
1031 });
1032 });
1033});
1034
1035describe("WorktreeMcpHandoffInput schema", () => {
1036 const decode = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput);
1037
1038 it.effect("accepts POSIX, Windows drive, and UNC absolute paths", () =>
1039 Effect.gen(function* () {
1040 for (const path of ["/abs/posix", "C:\\abs\\drive", "C:/abs/drive", "\\\\host\\share"]) {
1041 const decoded = yield* decode({ branch: "feature/x", path });
1042 expect(decoded.path).toBe(path);
1043 }
1044 }),
1045 );
1046
1047 it.effect("rejects relative paths", () =>
1048 Effect.gen(function* () {
1049 for (const path of ["worktrees/nested", "./nested", "../sibling"]) {
1050 const exit = yield* Effect.exit(decode({ branch: "feature/x", path }));
1051 expect(Exit.isFailure(exit), `path '${path}' should be rejected`).toBe(true);
1052 }
1053 }),
1054 );
1055
1056 it.effect("rejects a missing or blank branch", () =>
1057 Effect.gen(function* () {
1058 expect(Exit.isFailure(yield* Effect.exit(decode({})))).toBe(true);
1059 expect(Exit.isFailure(yield* Effect.exit(decode({ branch: " " })))).toBe(true);
1060 }),
1061 );
1062
1063 it.effect("rejects a blank continuationPrompt", () =>
1064 Effect.gen(function* () {
1065 const exit = yield* Effect.exit(decode({ branch: "feature/x", continuationPrompt: " " }));
1066 expect(Exit.isFailure(exit)).toBe(true);
1067 }),
1068 );
1069});