1import { expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type OrchestrationV2ThreadShell,
8 type Project as ProjectRecord,
9} from "@t3tools/contracts";
10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Stream from "effect/Stream";
18
19import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
20import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
21import * as ServerConfig from "../../../config.ts";
22import * as ServerSettings from "../../../serverSettings.ts";
23import * as Project from "../../../project/ProjectService.ts";
24import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
25import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts";
26import * as VcsProcess from "../../../vcs/VcsProcess.ts";
27import * as McpInvocationContext from "../../McpInvocationContext.ts";
28import * as McpToolAccess from "../../McpToolAccess.ts";
29import * as ProjectHandlers from "./handlers.ts";
30import { ProjectToolkit } from "./tools.ts";
31
32it.effect("attributes a launched thread's first message to the calling thread", () =>
33 Effect.gen(function* () {
34 const sourceThreadId = ThreadId.make("source-thread");
35 const projectId = ProjectId.make("project");
36 const providerInstanceId = ProviderInstanceId.make("codex");
37 const modelSelection = { instanceId: providerInstanceId, model: "gpt-5" };
38 const caller = {
39 id: sourceThreadId,
40 projectId,
41 providerInstanceId,
42 modelSelection,
43 runtimeMode: "full-access",
44 interactionMode: "default",
45 activeRunId: "active-run",
46 archivedAt: null,
47 deletedAt: null,
48 } as OrchestrationV2ThreadShell;
49 let launchedSender: ThreadId | undefined;
50 const layerDependencies = Layer.mergeAll(
51 ServerSettings.layerTest(),
52 NodeCrypto.layer,
53 Layer.succeed(McpInvocationContext.McpInvocationContext, {
54 environmentId: EnvironmentId.make("environment"),
55 requestNamespace: "session",
56 thread: {
57 threadId: sourceThreadId,
58 providerSessionId: "session",
59 providerInstanceId,
60 },
61 client: undefined,
62 issuedAt: 0,
63 capabilities: new Set(["orchestration" as const]),
64 }),
65 Layer.mock(ThreadManagement.ThreadManagementService)({
66 getThreadShell: () => Effect.succeed(caller),
67 }),
68 Layer.mock(ThreadLaunch.ThreadLaunchService)({
69 launch: (input) => {
70 launchedSender = input.initialMessage?.senderThreadId;
71 return Effect.succeed({
72 threadId: input.threadId,
73 projection: {
74 thread: { id: input.threadId, projectId, modelSelection },
75 runs: [],
76 },
77 resumed: false,
78 } as unknown as ThreadLaunch.ThreadLaunchResult);
79 },
80 }),
81 Layer.mock(Project.ProjectService)({}),
82 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
83 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
84 NodeServices.layer,
85 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-link-" }).pipe(
86 Layer.provide(NodeServices.layer),
87 ),
88 );
89 const toolkit = yield* ProjectToolkit.pipe(
90 Effect.provide(
91 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
92 Layer.provide(layerDependencies),
93 ),
94 ),
95 );
96 const result = yield* toolkit
97 .handle("t3_thread_launch", { title: "Audit", message: "Review the change" })
98 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
99 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
100 expect(launchedSender).toBe(sourceThreadId);
101 }),
102);
103
104it.effect("launches a scratch thread into the Scratch project", () =>
105 Effect.gen(function* () {
106 const sourceThreadId = ThreadId.make("source-thread");
107 const scratchProjectId = ProjectId.make("project:scratch");
108 const providerInstanceId = ProviderInstanceId.make("codex");
109 const modelSelection = { instanceId: providerInstanceId, model: "gpt-5" };
110 const caller = {
111 id: sourceThreadId,
112 projectId: ProjectId.make("project:caller"),
113 providerInstanceId,
114 modelSelection,
115 runtimeMode: "full-access",
116 interactionMode: "default",
117 activeRunId: "active-run",
118 archivedAt: null,
119 deletedAt: null,
120 } as OrchestrationV2ThreadShell;
121 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
122 const layerDependencies = Layer.mergeAll(
123 ServerSettings.layerTest(),
124 NodeCrypto.layer,
125 Layer.succeed(McpInvocationContext.McpInvocationContext, {
126 environmentId: EnvironmentId.make("environment"),
127 requestNamespace: "session",
128 thread: {
129 threadId: sourceThreadId,
130 providerSessionId: "session",
131 providerInstanceId,
132 },
133 client: undefined,
134 issuedAt: 0,
135 capabilities: new Set(["orchestration" as const]),
136 }),
137 Layer.mock(ThreadManagement.ThreadManagementService)({
138 getThreadShell: () => Effect.succeed(caller),
139 }),
140 Layer.mock(ThreadLaunch.ThreadLaunchService)({
141 launch: (input) => {
142 launched.push(input);
143 return Effect.succeed({
144 threadId: input.threadId,
145 projection: {
146 thread: { id: input.threadId, projectId: input.projectId, modelSelection },
147 runs: [],
148 },
149 resumed: false,
150 } as unknown as ThreadLaunch.ThreadLaunchResult);
151 },
152 }),
153 Layer.mock(Project.ProjectService)({}),
154 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
155 namedProjectsRoot: "/projects",
156 ensureScratchProject: Effect.succeed({ projectId: scratchProjectId }),
157 }),
158 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
159 NodeServices.layer,
160 ServerConfig.layerTest(process.cwd(), { prefix: "t3-scratch-launch-" }).pipe(
161 Layer.provide(NodeServices.layer),
162 ),
163 );
164 const toolkit = yield* ProjectToolkit.pipe(
165 Effect.provide(
166 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
167 Layer.provide(layerDependencies),
168 ),
169 ),
170 );
171 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
172 toolkit
173 .handle("t3_thread_launch", params)
174 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
175
176 const result = yield* handle({ title: "Notes", scratch: true, message: "Draft a list" });
177 expect(result.at(-1)?.result).toMatchObject({ projectId: scratchProjectId });
178 expect(launched.map((input) => [input.projectId, input.workspaceStrategy])).toEqual([
179 [scratchProjectId, { type: "root" }],
180 ]);
181
182 const rejected = yield* handle({
183 title: "Notes",
184 scratch: true,
185 projectId: caller.projectId,
186 });
187 expect(rejected.at(-1)?.result).toMatchObject({ code: "invalid_request" });
188 expect(launched).toHaveLength(1);
189 }),
190);
191
192it.effect("starts a project from just a title when workspaceRoot is omitted", () =>
193 Effect.gen(function* () {
194 const sourceThreadId = ThreadId.make("source-thread");
195 const createdProjectId = ProjectId.make("project:named");
196 const providerInstanceId = ProviderInstanceId.make("codex");
197 const caller = {
198 id: sourceThreadId,
199 projectId: ProjectId.make("project:caller"),
200 providerInstanceId,
201 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
202 runtimeMode: "full-access",
203 interactionMode: "default",
204 activeRunId: "active-run",
205 archivedAt: null,
206 deletedAt: null,
207 } as OrchestrationV2ThreadShell;
208 const named: Array<string> = [];
209 const registered: Array<string> = [];
210 const createdProject = {
211 id: createdProjectId,
212 title: "Pinball Stats",
213 workspaceRoot: "/projects/pinball-stats",
214 defaultModelSelection: null,
215 scripts: [],
216 createdAt: "2026-10-01T00:00:00.000Z",
217 updatedAt: "2026-10-01T00:00:00.000Z",
218 deletedAt: null,
219 };
220 const layerDependencies = Layer.mergeAll(
221 ServerSettings.layerTest(),
222 NodeCrypto.layer,
223 Layer.succeed(McpInvocationContext.McpInvocationContext, {
224 environmentId: EnvironmentId.make("environment"),
225 requestNamespace: "session",
226 thread: {
227 threadId: sourceThreadId,
228 providerSessionId: "session",
229 providerInstanceId,
230 },
231 client: undefined,
232 issuedAt: 0,
233 capabilities: new Set(["orchestration" as const]),
234 }),
235 Layer.mock(ThreadManagement.ThreadManagementService)({
236 getThreadShell: () => Effect.succeed(caller),
237 }),
238 Layer.mock(ThreadLaunch.ThreadLaunchService)({}),
239 Layer.mock(Project.ProjectService)({
240 create: (input) =>
241 Effect.sync(() => {
242 registered.push(input.workspaceRoot);
243 return { ...createdProject, id: input.projectId, workspaceRoot: input.workspaceRoot };
244 }),
245 getById: (projectId) =>
246 Effect.succeed(
247 projectId === createdProjectId ? Option.some(createdProject) : Option.none(),
248 ),
249 }),
250 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
251 namedProjectsRoot: "/projects",
252 createNamedProject: (input) =>
253 Effect.sync(() => {
254 named.push(input.name);
255 return {
256 projectId: createdProjectId,
257 workspaceRoot: createdProject.workspaceRoot,
258 commitError: "Git has no name or email on this machine.",
259 };
260 }),
261 }),
262 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
263 NodeServices.layer,
264 ServerConfig.layerTest(process.cwd(), { prefix: "t3-named-project-" }).pipe(
265 Layer.provide(NodeServices.layer),
266 ),
267 );
268 const toolkit = yield* ProjectToolkit.pipe(
269 Effect.provide(
270 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(
271 Layer.provide(layerDependencies),
272 ),
273 ),
274 );
275 const handle = (params: Parameters<typeof toolkit.handle<"t3_project_create">>[1]) =>
276 toolkit
277 .handle("t3_project_create", params)
278 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies));
279
280 const result = yield* handle({ title: "Pinball Stats" });
281 expect(result.at(-1)?.result).toMatchObject({
282 id: createdProjectId,
283 workspaceRoot: "/projects/pinball-stats",
284 commitError: "Git has no name or email on this machine.",
285 });
286 expect(named).toEqual(["Pinball Stats"]);
287
288 // A path still registers that folder, and never makes a named project.
289 yield* handle({ title: "Existing", workspaceRoot: "/work/existing" });
290 expect(registered).toEqual(["/work/existing"]);
291
292 // Fields this mode cannot apply are rejected, not dropped.
293 for (const extra of [
294 { scripts: [] },
295 { defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" } },
296 ]) {
297 const rejected = yield* handle({ title: "Configured", ...extra });
298 expect(rejected.at(-1)?.result).toMatchObject({ code: "invalid_request" });
299 }
300 expect(named).toEqual(["Pinball Stats"]);
301 }),
302);
303
304const clientLaunchHarness = (input: {
305 readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access";
306 readonly launched: Array<ThreadLaunch.ThreadLaunchInput>;
307 readonly workspaceRoot?: string;
308 readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"];
309}) => {
310 const projectId = ProjectId.make("project:client-target");
311 // "claudeAgent" is the real default instance id for the Claude driver
312 // (see `defaultInstanceIdForDriver`), so it resolves as enabled by default
313 // under the project-scoped provider instance gate the launch path now runs.
314 const modelSelection = {
315 instanceId: ProviderInstanceId.make("claudeAgent"),
316 model: "claude-opus",
317 };
318 const layerDependencies = Layer.mergeAll(
319 ServerSettings.layerTest(),
320 NodeCrypto.layer,
321 Layer.succeed(McpInvocationContext.McpInvocationContext, {
322 environmentId: EnvironmentId.make("environment"),
323 requestNamespace: "client:session-1",
324 thread: undefined,
325 client: {
326 sessionId: "session-1",
327 label: "Claude Code",
328 access: input.runtimeModeCeiling,
329 },
330 issuedAt: 0,
331 capabilities: new Set(["orchestration" as const]),
332 }),
333 Layer.mock(ThreadManagement.ThreadManagementService)({}),
334 Layer.mock(ThreadLaunch.ThreadLaunchService)({
335 checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void),
336 launch: (launch) => {
337 input.launched.push(launch);
338 return Effect.succeed({
339 threadId: launch.threadId,
340 projection: {
341 thread: {
342 id: launch.threadId,
343 projectId: launch.projectId,
344 modelSelection: launch.modelSelection,
345 },
346 runs: [],
347 },
348 resumed: false,
349 } as unknown as ThreadLaunch.ThreadLaunchResult);
350 },
351 }),
352 Layer.mock(Project.ProjectService)({
353 getById: (id) =>
354 Effect.succeed(
355 id === projectId
356 ? Option.some({
357 id,
358 workspaceRoot: input.workspaceRoot ?? "/projects/client-target",
359 defaultModelSelection: modelSelection,
360 } as unknown as ProjectRecord)
361 : Option.none(),
362 ),
363 }),
364 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }),
365 NodeServices.layer,
366 ).pipe(
367 Layer.provideMerge(GitVcsDriver.layer),
368 Layer.provideMerge(VcsProcess.layer),
369 Layer.provideMerge(
370 ServerConfig.layerTest(process.cwd(), { prefix: "t3-client-launch-" }).pipe(
371 Layer.provide(NodeServices.layer),
372 ),
373 ),
374 Layer.provideMerge(NodeServices.layer),
375 );
376 return { projectId, modelSelection, dependencies: layerDependencies };
377};
378
379it.effect("a client launches at its ceiling with the project's default model", () =>
380 Effect.gen(function* () {
381 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
382 const { projectId, modelSelection, dependencies } = clientLaunchHarness({
383 runtimeModeCeiling: "auto-accept-edits",
384 launched,
385 });
386 const toolkit = yield* ProjectToolkit.pipe(
387 Effect.provide(
388 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
389 ),
390 );
391 const handle = (params: Parameters<typeof toolkit.handle<"t3_thread_launch">>[1]) =>
392 toolkit
393 .handle("t3_thread_launch", params)
394 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
395
396 const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" });
397 expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection });
398 expect(launched[0]?.runtimeMode).toBe("auto-accept-edits");
399 expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined();
400
401 const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" });
402 expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" });
403
404 const untargeted = yield* handle({ title: "Fix" });
405 expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" });
406 expect(launched).toHaveLength(1);
407 }),
408);
409
410it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () =>
411 Effect.gen(function* () {
412 const fileSystem = yield* FileSystem.FileSystem;
413 const path = yield* Path.Path;
414 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-launch-worktree-" });
415 const repo = path.join(root, "repo");
416 const worktree = path.join(root, "feature");
417 const outside = path.join(root, "outside");
418 yield* fileSystem.makeDirectory(repo);
419 yield* fileSystem.makeDirectory(outside);
420
421 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
422 const { projectId, dependencies } = clientLaunchHarness({
423 runtimeModeCeiling: "auto",
424 launched,
425 workspaceRoot: repo,
426 });
427 const git = yield* GitVcsDriver.GitVcsDriver.pipe(Effect.provide(dependencies));
428 for (const args of [
429 ["init", "-b", "main"],
430 ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"],
431 ["worktree", "add", "-b", "feature", worktree],
432 ]) {
433 yield* git.execute({ operation: "test.setupRepo", cwd: repo, args });
434 }
435 const toolkit = yield* ProjectToolkit.pipe(
436 Effect.provide(
437 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
438 ),
439 );
440 const launchInto = (worktreePath: string) =>
441 toolkit
442 .handle("t3_thread_launch", {
443 title: "Fix",
444 projectId,
445 workspaceStrategy: { type: "existing_worktree", worktreePath },
446 })
447 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
448
449 expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ projectId });
450 expect((yield* launchInto(repo)).at(-1)?.result).toMatchObject({ projectId });
451 for (const elsewhere of [outside, path.join(worktree, "..", "outside"), "/"]) {
452 expect((yield* launchInto(elsewhere)).at(-1)?.result).toMatchObject({
453 code: "invalid_request",
454 });
455 }
456 expect(launched.map((launch) => launch.workspaceStrategy)).toEqual([
457 { type: "existing_worktree", worktreePath: worktree },
458 { type: "existing_worktree", worktreePath: repo },
459 ]);
460
461 // A removed worktree stays listed as prunable until `git worktree prune`;
462 // whatever directory is later made at its path is not one of the project's.
463 yield* fileSystem.remove(worktree, { recursive: true });
464 yield* fileSystem.makeDirectory(worktree);
465 expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({
466 code: "invalid_request",
467 });
468 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
469);
470
471it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () =>
472 Effect.gen(function* () {
473 const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
474 const { projectId, dependencies } = clientLaunchHarness({
475 runtimeModeCeiling: "auto",
476 launched,
477 checkWorktreeBase: ({ projectId, workspaceStrategy }) =>
478 workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed"
479 ? Effect.fail(
480 new ThreadLaunch.ThreadLaunchBaseRefError({
481 projectId,
482 projectTitle: "Client target",
483 workspaceRoot: "/projects/client-target",
484 baseRef: workspaceStrategy.baseRef,
485 }),
486 )
487 : Effect.void,
488 });
489 const toolkit = yield* ProjectToolkit.pipe(
490 Effect.provide(
491 McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
492 ),
493 );
494 const launchFrom = (baseRef: string) =>
495 toolkit
496 .handle("t3_thread_launch", {
497 title: "Fix",
498 projectId,
499 workspaceStrategy: { type: "worktree", baseRef, startFromOrigin: false },
500 })
501 .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
502
503 const refused = (yield* launchFrom("t3/renamed")).at(-1)?.result;
504 expect(refused).toMatchObject({ code: "invalid_request" });
505 expect((refused as { message: string }).message).toContain(
506 'Base ref "t3/renamed" does not resolve to a commit in project "Client target" (/projects/client-target). No thread was created.',
507 );
508 expect(launched).toHaveLength(0);
509
510 expect((yield* launchFrom("main")).at(-1)?.result).toMatchObject({ projectId });
511 expect(launched).toHaveLength(1);
512 }),
513);