diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index ce9e872b9774a..fb8bfb14230da 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -261,7 +261,12 @@ function makeHarness(options: HarnessOptions = {}) { }); const layerTitleRegeneration = ThreadTitleRegeneration.layer.pipe( Layer.provide( - Layer.mergeAll(layerThreadManagement, layerProjectedProjects, layerExternalServices), + Layer.mergeAll( + layerThreadManagement, + layerProjectedProjects, + layerExternalServices, + NodeServices.layer, + ), ), ); return { diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..c54e6e1f4815d 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, ChatAttachmentId, @@ -14,6 +15,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; @@ -98,6 +100,7 @@ function makeHarness( layerProjectedProjects, Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }), layerServerSettings, + NodeServices.layer, ), ), ); @@ -113,7 +116,11 @@ function makeHarness( }; } -function createThread(input: { readonly command: string; readonly thread: string }) { +function createThread(input: { + readonly command: string; + readonly thread: string; + readonly worktreePath?: string; +}) { return Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const threadId = ThreadId.make(input.thread); @@ -127,7 +134,7 @@ function createThread(input: { readonly command: string; readonly thread: string runtimeMode: "full-access", interactionMode: "default", branch: null, - worktreePath: null, + worktreePath: input.worktreePath ?? null, createdBy: "user", creationSource: "web", }); @@ -391,31 +398,43 @@ describe("ThreadTitleRegenerationService", () => { const harness = makeHarness({ generateTitle: () => Effect.succeed({ title: "Fresh title" }), }); + const fs = yield* FileSystem.FileSystem; + const existingWorktree = yield* fs.makeTempDirectoryScoped({ prefix: "title-worktree-" }); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const threadId = yield* createThread({ - command: "command:title:landing:create", - thread: "thread:title:landing", - }); - yield* dispatchUserMessage({ - command: "command:title:landing:message", - threadId, - text: "Investigate the flaky login test", - }); - const requestId = yield* armRegeneration({ command: "command:title:landing:1", threadId }); + const cases = [ + { name: "removed", worktreePath: "/repo-worktrees/removed-by-cleanup", cwd: "/repo" }, + { name: "existing", worktreePath: existingWorktree, cwd: existingWorktree }, + ]; + for (const [index, testCase] of cases.entries()) { + const threadId = yield* createThread({ + command: `command:title:landing:${testCase.name}:create`, + thread: `thread:title:landing:${testCase.name}`, + worktreePath: testCase.worktreePath, + }); + yield* dispatchUserMessage({ + command: `command:title:landing:${testCase.name}:message`, + threadId, + text: "Investigate the flaky login test", + }); + const requestId = yield* armRegeneration({ + command: `command:title:landing:${testCase.name}:1`, + threadId, + }); - yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); + yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); - const call = harness.generateThreadTitle.mock.calls[0]?.[0]; - assert.equal(call?.previousTitle, "Seed title"); - assert.equal(call?.cwd, "/repo"); - assert.include(call?.message, "USER:\nInvestigate the flaky login test"); - const projection = yield* threads.getThreadProjection(threadId); - assert.equal(projection.thread.title, "Fresh title"); - assert.isNotOk(projection.thread.titleRegeneration); + const call = harness.generateThreadTitle.mock.calls[index]?.[0]; + assert.equal(call?.previousTitle, "Seed title"); + assert.equal(call?.cwd, testCase.cwd); + assert.include(call?.message, "USER:\nInvestigate the flaky login test"); + const projection = yield* threads.getThreadProjection(threadId); + assert.equal(projection.thread.title, "Fresh title"); + assert.isNotOk(projection.thread.titleRegeneration); + } }).pipe(Effect.provide(harness.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("titles a fork's first message with the source history up to the fork point", () => diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 601b783675d0e..6c80def39059f 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -13,6 +13,7 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; @@ -50,6 +51,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectStore.ProjectStoreV2; const serverSettings = yield* ServerSettings.ServerSettingsService; const textGeneration = yield* TextGeneration.TextGeneration; + const fileSystem = yield* FileSystem.FileSystem; const complete = (input: { readonly threadId: ThreadId; @@ -160,8 +162,13 @@ const make = Effect.gen(function* () { yield* serverSettings.getSettings, projection.thread.projectId, ).settings; + // Storage cleanup removes worktrees but keeps the path; CLIs cannot spawn in a missing cwd. + const worktreePath = projection.thread.worktreePath; + const worktreeExists = + worktreePath !== null && + (yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => false))); const result = yield* textGeneration.generateThreadTitle({ - cwd: projection.thread.worktreePath ?? project.value.workspaceRoot, + cwd: worktreeExists ? worktreePath : project.value.workspaceRoot, message: context.message, attachments: context.attachments, ...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}),