| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | EnvironmentId, |
| 4 | ProjectId, |
| 5 | ProviderInstanceId, |
| 6 | ThreadId, |
| 7 | type OrchestrationV2ThreadShell, |
| 8 | type Project as ProjectRecord, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Path from "effect/Path"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Stream from "effect/Stream"; |
| 18 | |
| 19 | import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; |
| 20 | import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 21 | import * as ServerConfig from "../../../config.ts"; |
| 22 | import * as ServerSettings from "../../../serverSettings.ts"; |
| 23 | import * as Project from "../../../project/ProjectService.ts"; |
| 24 | import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; |
| 25 | import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; |
| 26 | import * as VcsProcess from "../../../vcs/VcsProcess.ts"; |
| 27 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 28 | import * as McpToolAccess from "../../McpToolAccess.ts"; |
| 29 | import * as ProjectHandlers from "./handlers.ts"; |
| 30 | import { ProjectToolkit } from "./tools.ts"; |
| 31 | |
| 32 | it.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 | |
| 104 | it.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 | |
| 192 | it.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 | |
| 304 | const 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 | |
| 379 | it.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 | |
| 410 | it.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 | |
| 471 | it.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 | ); |