diff --git a/.docs/scripts.md b/.docs/scripts.md index f62af9ffb4..b3fcd4b30e 100644 --- a/.docs/scripts.md +++ b/.docs/scripts.md @@ -5,7 +5,7 @@ - `bun run dev:web` — Starts just the Vite dev server for the web app. - Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. - Override server CLI-equivalent flags from root dev commands with `--`, for example: - `bun run dev -- --state-dir ~/.t3/another-dev-state` + `bun run dev -- --base-dir ~/.t3-2` - `bun run start` — Runs the production server (serves built web app as static files). - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. diff --git a/.gitignore b/.gitignore index 3e8d287755..f5c9ebb69b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ -.vitest-* \ No newline at end of file +.vitest-* +__screenshots__/ \ No newline at end of file diff --git a/REMOTE.md b/REMOTE.md index 8bbd481dea..5eed2f803e 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -11,7 +11,7 @@ The T3 Code CLI accepts the following configuration options, available either as | `--mode ` | `T3CODE_MODE` | Runtime mode. | | `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | | `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--state-dir ` | `T3CODE_STATE_DIR` | State directory. | +| `--base-dir ` | `T3CODE_HOME` | Base directory. | | `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d65a954455..6e7b943415 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -61,8 +61,8 @@ const LOG_DIR_CHANNEL = "desktop:log-dir"; const LOG_LIST_CHANNEL = "desktop:log-list"; const LOG_READ_CHANNEL = "desktop:log-read"; const LOG_OPEN_DIR_CHANNEL = "desktop:log-open-dir"; -const STATE_DIR = - process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); +const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); +const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -929,7 +929,7 @@ function backendEnv(): NodeJS.ProcessEnv { T3CODE_MODE: "desktop", T3CODE_NO_BROWSER: "1", T3CODE_PORT: String(backendPort), - T3CODE_STATE_DIR: STATE_DIR, + T3CODE_HOME: BASE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, }; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c237246ab2..19f34b49a0 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { execFileSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -13,9 +10,11 @@ import { import { Effect, Exit, + FileSystem, Layer, ManagedRuntime, Option, + Path, Ref, Schedule, Schema, @@ -66,7 +65,7 @@ import { makeTestProviderAdapterHarness, type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; -import { ServerConfig } from "../src/config.ts"; +import { deriveServerPaths, ServerConfig } from "../src/config.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -76,14 +75,16 @@ function runGit(cwd: string, args: ReadonlyArray) { }); } -function initializeGitWorkspace(cwd: string) { +const initializeGitWorkspace = Effect.fn(function* (cwd: string) { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); - fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + const fileSystem = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); -} +}); export function gitRefExists(cwd: string, ref: string): boolean { try { @@ -214,7 +215,9 @@ export const makeOrchestrationIntegrationHarness = ( options?: MakeOrchestrationIntegrationHarnessOptions, ) => Effect.gen(function* () { - const sleep = (ms: number) => Effect.sleep(ms); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const provider = options?.provider ?? "codex"; const useRealCodex = options?.realCodex === true; const adapterHarness = useRealCodex @@ -231,13 +234,16 @@ export const makeOrchestrationIntegrationHarness = ( listProviders: () => Effect.succeed([adapterHarness.provider]), } as typeof ProviderAdapterRegistry.Service) : null; - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); + const rootDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-orchestration-integration-", + }); const workspaceDir = path.join(rootDir, "workspace"); - const stateDir = path.join(rootDir, "state"); - const dbPath = path.join(stateDir, "state.sqlite"); - fs.mkdirSync(workspaceDir, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - initializeGitWorkspace(workspaceDir); + const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe( + Effect.provideService(Path.Path, path), + ); + yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); + yield* fileSystem.makeDirectory(stateDir, { recursive: true }); + yield* initializeGitWorkspace(workspaceDir); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -262,7 +268,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -312,7 +318,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); @@ -352,7 +358,7 @@ export const makeOrchestrationIntegrationHarness = ( yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), ).pipe(Effect.forkIn(scope)); - yield* sleep(10); + yield* Effect.sleep(10); const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( threadId, @@ -469,13 +475,7 @@ export const makeOrchestrationIntegrationHarness = ( } }); - yield* shutdown.pipe( - Effect.ensuring( - Effect.sync(() => { - fs.rmSync(rootDir, { recursive: true, force: true }); - }), - ), - ); + yield* shutdown; }); return { diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 2c116751e6..3373a18071 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -27,6 +27,7 @@ import type { CheckpointDiffFinalizedReceipt, TurnProcessingQuiescedReceipt, } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); @@ -51,8 +52,6 @@ class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass Effect.sleep(ms); - function waitForSync( read: () => A, predicate: (value: A) => boolean, @@ -70,7 +69,7 @@ function waitForSync( if (Date.now() >= deadline) { return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); } - yield* sleep(10); + yield* Effect.sleep(10); } }); } @@ -91,7 +90,7 @@ function withHarness( makeOrchestrationIntegrationHarness({ provider }), use, (harness) => harness.dispose, - ); + ).pipe(Effect.provide(NodeServices.layer)); } function withRealCodexHarness( @@ -101,7 +100,7 @@ function withRealCodexHarness( makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), use, (harness) => harness.dispose, - ); + ).pipe(Effect.provide(NodeServices.layer)); } const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index c1680f6c08..cd7e1faec8 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -11,7 +11,7 @@ export function normalizeAttachmentRelativePath(rawRelativePath: string): string } export function resolveAttachmentRelativePath(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly relativePath: string; }): string | null { const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath); @@ -19,7 +19,7 @@ export function resolveAttachmentRelativePath(input: { return null; } - const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments")); + const attachmentsRoot = path.resolve(input.attachmentsDir); const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { return null; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 8e1bc4218e..e92c3d219d 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -44,34 +44,32 @@ describe("attachmentStore", () => { }); it("resolves attachment path by id using the extension that exists on disk", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); try { const attachmentId = "thread-1-attachment"; - const attachmentsDir = path.join(stateDir, "attachments"); - fs.mkdirSync(attachmentsDir, { recursive: true }); const pngPath = path.join(attachmentsDir, `${attachmentId}.png`); fs.writeFileSync(pngPath, Buffer.from("hello")); const resolved = resolveAttachmentPathById({ - stateDir, + attachmentsDir, attachmentId, }); expect(resolved).toBe(pngPath); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(attachmentsDir, { recursive: true, force: true }); } }); it("returns null when no attachment file exists for the id", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); try { const resolved = resolveAttachmentPathById({ - stateDir, + attachmentsDir, attachmentId: "thread-1-missing", }); expect(resolved).toBeNull(); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(attachmentsDir, { recursive: true, force: true }); } }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3440e29fc3..aa85b8c51a 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -66,17 +66,17 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { } export function resolveAttachmentPath(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly attachment: ChatAttachment; }): string | null { return resolveAttachmentRelativePath({ - stateDir: input.stateDir, + attachmentsDir: input.attachmentsDir, relativePath: attachmentRelativePath(input.attachment), }); } export function resolveAttachmentPathById(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly attachmentId: string; }): string | null { const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); @@ -85,7 +85,7 @@ export function resolveAttachmentPathById(input: { } for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { const maybePath = resolveAttachmentRelativePath({ - stateDir: input.stateDir, + attachmentsDir: input.attachmentsDir, relativePath: `${normalizedId}${extension}`, }); if (maybePath && existsSync(maybePath)) { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d..8553ce9667 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -12,16 +12,32 @@ export const DEFAULT_PORT = 3773; export type RuntimeMode = "web" | "desktop"; +/** + * ServerDerivedPaths - Derived paths from the base directory. + */ +export interface ServerDerivedPaths { + readonly stateDir: string; + readonly dbPath: string; + readonly keybindingsConfigPath: string; + readonly worktreesDir: string; + readonly attachmentsDir: string; + readonly logsDir: string; + readonly serverLogPath: string; + readonly providerLogsDir: string; + readonly providerEventLogPath: string; + readonly terminalLogsDir: string; + readonly anonymousIdPath: string; +} + /** * ServerConfigShape - Process/runtime configuration required by the server. */ -export interface ServerConfigShape { +export interface ServerConfigShape extends ServerDerivedPaths { readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; readonly cwd: string; - readonly keybindingsConfigPath: string; - readonly stateDir: string; + readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; @@ -30,31 +46,68 @@ export interface ServerConfigShape { readonly logWebSocketEvents: boolean; } +export const deriveServerPaths = Effect.fn(function* ( + baseDir: ServerConfigShape["baseDir"], + devUrl: ServerConfigShape["devUrl"], +): Effect.fn.Return { + const { join } = yield* Path.Path; + const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata"); + const dbPath = join(stateDir, "state.sqlite"); + const attachmentsDir = join(stateDir, "attachments"); + const logsDir = join(stateDir, "logs"); + const providerLogsDir = join(logsDir, "provider"); + return { + stateDir, + dbPath, + keybindingsConfigPath: join(stateDir, "keybindings.json"), + worktreesDir: join(baseDir, "worktrees"), + attachmentsDir, + logsDir, + serverLogPath: join(logsDir, "server.log"), + providerLogsDir, + providerEventLogPath: join(providerLogsDir, "events.log"), + terminalLogsDir: join(logsDir, "terminals"), + anonymousIdPath: join(stateDir, "anonymous-id"), + }; +}); + /** * ServerConfig - Service tag for server runtime configuration. */ export class ServerConfig extends ServiceMap.Service()( "t3/config/ServerConfig", ) { - static readonly layerTest = (cwd: string, statedir: string) => + static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) => Layer.effect( ServerConfig, Effect.gen(function* () { - const path = yield* Path.Path; + const devUrl = undefined; + + const fs = yield* FileSystem.FileSystem; + const baseDir = + typeof baseDirOrPrefix === "string" + ? baseDirOrPrefix + : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + + yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true }); + yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true }); + yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }); + return { cwd, - stateDir: statedir, + baseDir, + ...derivedPaths, mode: "web", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, port: 0, host: undefined, authToken: undefined, - keybindingsConfigPath: path.join(statedir, "keybindings.json"), staticDir: undefined, - devUrl: undefined, + devUrl, noBrowser: false, - }; + } satisfies ServerConfigShape; }), ); } diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e092..0170d207fe 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -8,11 +8,14 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -const makeCodexTextGenerationTestLayer = (stateDir: string) => - CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), - Layer.provideMerge(NodeServices.layer), - ); +const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-codex-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); function makeFakeCodexBinary(dir: string) { return Effect.gen(function* () { @@ -186,8 +189,6 @@ function withFakeCodexEnv( ); } -const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd()); - it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( @@ -325,9 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); - yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); yield* fs.writeFile(attachmentPath, Buffer.from("hello")); const textGeneration = yield* TextGeneration; @@ -363,9 +365,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); - yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); yield* fs.writeFile(imagePath, Buffer.from("hello")); const textGeneration = yield* TextGeneration; @@ -410,8 +413,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const missingPath = path.join(process.cwd(), "attachments", `${missingAttachmentId}.png`); + const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); const textGeneration = yield* TextGeneration; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93f0..3ab20c22a6 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -163,7 +163,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { } const resolvedPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!resolvedPath || !path.isAbsolute(resolvedPath)) { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8a..6d50cd2504 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -12,12 +12,15 @@ import { GitCoreLive } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "../Errors.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; +import { ServerConfig } from "../../config.ts"; // ── Helpers ── const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(GitServiceTestLayer), + Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); @@ -90,6 +93,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => const gitServiceLayer = Layer.succeed(GitService, gitService); const coreLayer = GitCoreLive.pipe( Layer.provide(gitServiceLayer), + Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 004de1a581..edd05d4906 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -3,6 +3,7 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef import { GitCommandError } from "../Errors.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { ServerConfig } from "../../config.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -221,6 +222,7 @@ const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; const executeGit = ( operation: string, @@ -1218,9 +1220,7 @@ const makeGitCore = Effect.gen(function* () { const targetBranch = input.newBranch ?? input.branch; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; - const worktreePath = - input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); const args = input.newBranch ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] : ["worktree", "add", worktreePath, input.branch]; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 78f8c3cac0..a48aeac288 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -32,6 +32,7 @@ import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; +import { ServerConfig } from "../../config.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -514,10 +515,14 @@ function makeManager(input?: { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); const sessionTextGeneration = createSessionTextGeneration(input?.sessionTextGeneration); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-manager-test-", + }); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerConfigLayer), ); const managerLayer = Layer.mergeAll( @@ -525,8 +530,7 @@ function makeManager(input?: { Layer.succeed(TextGeneration, textGeneration), Layer.succeed(SessionTextGeneration, sessionTextGeneration), gitCoreLayer, - NodeServices.layer, - ); + ).pipe(Layer.provideMerge(NodeServices.layer)); return makeGitManager.pipe( Effect.provide(managerLayer), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6954cafc59..846c3778b0 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig, type ServerConfigShape } from "./config"; +import { ServerConfig } from "./config"; import { DEFAULT_KEYBINDINGS, @@ -17,21 +17,17 @@ import { } from "./keybindings"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const makeKeybindingsLayer = () => - KeybindingsLive.pipe( +const makeKeybindingsLayer = () => { + return KeybindingsLive.pipe( Layer.provideMerge( - Layer.effect( - ServerConfig, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { join } = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" }); - const configPath = join(dir, "keybindings.json"); - return { keybindingsConfigPath: configPath } as ServerConfigShape; + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-keybindings-test-", }), ), ), ); +}; const toDetailResult = (effect: Effect.Effect) => effect.pipe( @@ -42,7 +38,9 @@ const toDetailResult = (effect: Effect.Effect Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules); + yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true }); yield* fileSystem.writeFileString(configPath, encoded); }); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index c13a9ba80e..dc90a44bb2 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -60,13 +60,11 @@ const runCli = ( args: ReadonlyArray, env: Record = { T3CODE_NO_BROWSER: "true" }, ) => { - const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( Effect.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ env: { - T3CODE_STATE_DIR: uniqueStateDir, ...env, }, }), @@ -95,8 +93,8 @@ it.layer(testLayer)("server CLI command", (it) => { "4010", "--host", "0.0.0.0", - "--state-dir", - "/tmp/t3-cli-state", + "--home-dir", + "/tmp/t3-cli-home", "--dev-url", "http://127.0.0.1:5173", "--no-browser", @@ -108,7 +106,8 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4010); assert.equal(resolvedConfig?.host, "0.0.0.0"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "auth-secret"); @@ -134,7 +133,7 @@ it.layer(testLayer)("server CLI command", (it) => { T3CODE_MODE: "desktop", T3CODE_PORT: "4999", T3CODE_HOST: "100.88.10.4", - T3CODE_STATE_DIR: "/tmp/t3-env-state", + T3CODE_HOME: "/tmp/t3-env-home", VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", @@ -144,7 +143,8 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4999); assert.equal(resolvedConfig?.host, "100.88.10.4"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..17bf7f32f7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,12 +11,13 @@ import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, + deriveServerPaths, resolveStaticDir, ServerConfig, type RuntimeMode, type ServerConfigShape, } from "./config"; -import { fixPath, resolveStateDir } from "./os-jank"; +import { fixPath, resolveBaseDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; @@ -36,7 +37,7 @@ interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; - readonly stateDir: Option.Option; + readonly t3Home: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; @@ -99,10 +100,7 @@ const CliEnvConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - stateDir: Config.string("T3CODE_STATE_DIR").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, @@ -152,10 +150,10 @@ const ServerConfigLive = (input: CliInput) => return findAvailablePort(DEFAULT_PORT); }, }); - const stateDir = yield* resolveStateDir( - Option.getOrUndefined(input.stateDir) ?? env.stateDir, - ); + const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -167,8 +165,6 @@ const ServerConfigLive = (input: CliInput) => env.logWebSocketEvents ?? Boolean(devUrl), ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -178,9 +174,9 @@ const ServerConfigLive = (input: CliInput) => mode, port, cwd: cliConfig.cwd, - keybindingsConfigPath, host, - stateDir, + baseDir, + ...derivedPaths, staticDir, devUrl, noBrowser, @@ -299,8 +295,8 @@ const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); -const stateDirFlag = Flag.string("state-dir").pipe( - Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), +const t3HomeFlag = Flag.string("home-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), Flag.optional, ); const devUrlFlag = Flag.string("dev-url").pipe( @@ -335,7 +331,7 @@ export const t3Cli = Command.make("t3", { mode: modeFlag, port: portFlag, host: hostFlag, - stateDir: stateDirFlag, + t3Home: t3HomeFlag, devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1f366eacbe..35d2b5cd37 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -253,12 +253,16 @@ describe("CheckpointReactor", () => { Layer.provide(SqlitePersistenceMemory), ); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-checkpoint-reactor-test-", + }); + const layer = CheckpointReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 181b18d60c..0aa204d829 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -35,12 +35,15 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); async function createOrchestrationSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orchestration-engine-test-", + }); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(orchestrationLayer); @@ -317,13 +320,17 @@ describe("OrchestrationEngine", () => { }, }; + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orchestration-engine-test-", + }); + const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 5fbe3016fd..83ee080fbe 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -10,10 +10,7 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer, ManagedRuntime } from "effect"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { Effect, FileSystem, Layer, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -32,31 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; -const makeProjectionPipelineTestLayer = (stateDir: string) => +const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix })), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ); -const runWithProjectionPipelineLayer = ( - stateDir: string, - effect: Effect.Effect< - A, - E, - OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient - >, -) => - Effect.acquireUseRelease( - Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))), - (runtime) => Effect.promise(() => runtime.runPromise(effect)), - (runtime) => Effect.promise(() => runtime.dispose()), - ); +const exists = (filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const fileInfo = yield* Effect.result(fileSystem.stat(filePath)); + return fileInfo._tag === "Success"; + }); -const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd())); +const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); -projectionLayer("OrchestrationProjectionPipeline", (it) => { +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -174,157 +164,155 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { } }), ); +}); - it.effect("stores message attachment references without mutating payloads", () => - Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments"), - messageId: MessageId.makeUnsafe("message-attachments"), - role: "user", - text: "Inspect this", - attachments: [ - { - type: "image", - id: "thread-attachments-att-1", - name: "example.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("stores message attachment references without mutating payloads", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments"), + messageId: MessageId.makeUnsafe("message-attachments"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` SELECT attachments_json AS "attachmentsJson" FROM projection_thread_messages WHERE message_id = 'message-attachments' `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - id: "thread-attachments-att-1", - name: "example.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), - ); + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-attachments-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), + ); + }, +); - it.effect("preserves mixed image attachment metadata as-is", () => - Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments-safe"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments-safe"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments-safe"), - messageId: MessageId.makeUnsafe("message-attachments-safe"), - role: "user", - text: "Inspect this", - attachments: [ - { - type: "image", - id: "thread-attachments-safe-att-1", - name: "untrusted.exe", - mimeType: "image/x-unknown", - sizeBytes: 5, - }, - { - type: "image", - id: "thread-attachments-safe-att-2", - name: "not-image.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("preserves mixed image attachment metadata as-is", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments-safe"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments-safe"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments-safe"), + messageId: MessageId.makeUnsafe("message-attachments-safe"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-safe-att-1", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + }, + { + type: "image", + id: "thread-attachments-safe-att-2", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` SELECT attachments_json AS "attachmentsJson" FROM projection_thread_messages WHERE message_id = 'message-attachments-safe' `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - id: "thread-attachments-safe-att-1", - name: "untrusted.exe", - mimeType: "image/x-unknown", - sizeBytes: 5, - }, - { - type: "image", - id: "thread-attachments-safe-att-2", - name: "not-image.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), - ); + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-attachments-safe-att-1", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + }, + { + type: "image", + id: "thread-attachments-safe-att-2", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), + ); + }, +); +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect( "passes explicit empty attachment arrays through the projection pipeline to clear attachments", () => @@ -447,136 +435,110 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("overwrites stored attachment references when a message updates attachments", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); - - yield* eventStore.append({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-overwrite-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-1"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Project Overwrite", - workspaceRoot: "/tmp/project-overwrite", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-overwrite-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-2"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Thread Overwrite", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-3"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "first image", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-1", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-4"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: later, - commandId: CommandId.makeUnsafe("cmd-overwrite-4"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-2", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: later, - }, - }); + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const later = new Date(Date.now() + 1_000).toISOString(); - yield* projectionPipeline.bootstrap; + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-overwrite-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Project Overwrite", + workspaceRoot: "/tmp/project-overwrite", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` - SELECT attachments_json AS "attachmentsJson" - FROM projection_thread_messages - WHERE message_id = 'message-overwrite' - `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-overwrite-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Thread Overwrite", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "first image", + attachments: [ + { + type: "image", + id: "thread-overwrite-att-1", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-overwrite-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "", + attachments: [ { type: "image", id: "thread-overwrite-att-2", @@ -584,76 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { mimeType: "image/png", sizeBytes: 5, }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-overwrite' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-overwrite-att-2", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-rollback-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("does not persist attachment files when projector transaction rolls back", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-rollback-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Project Rollback", - workspaceRoot: "/tmp/project-rollback", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-rollback-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Thread Rollback", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - yield* sql` + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-rollback-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Project Rollback", + workspaceRoot: "/tmp/project-rollback", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-rollback-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Thread Rollback", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* sql` CREATE TRIGGER fail_thread_messages_projection_state_update BEFORE UPDATE ON projection_state WHEN NEW.projector = 'projection.thread-messages' @@ -662,453 +646,437 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { END; `; - const result = yield* Effect.result( - appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-rollback-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - messageId: MessageId.makeUnsafe("message-rollback"), - role: "user", - text: "Rollback me", - attachments: [ - { - type: "image", - id: "thread-rollback-att-1", - name: "rollback.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, + const result = yield* Effect.result( + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-rollback-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + messageId: MessageId.makeUnsafe("message-rollback"), + role: "user", + text: "Rollback me", + attachments: [ + { + type: "image", + id: "thread-rollback-att-1", + name: "rollback.png", + mimeType: "image/png", + sizeBytes: 5, }, - }), - ); - assert.equal(result._tag, "Failure"); + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + assert.equal(result._tag, "Failure"); - const rows = yield* sql<{ - readonly count: number; - }>` + const rows = yield* sql<{ + readonly count: number; + }>` SELECT COUNT(*) AS "count" FROM projection_thread_messages WHERE message_id = 'message-rollback' `; - assert.equal(rows[0]?.count ?? 0, 0); - - const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png"); - assert.equal(fs.existsSync(attachmentPath), false); - yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), + assert.equal(rows[0]?.count ?? 0, 0); + + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png"); + assert.isFalse(yield* exists(attachmentPath)); + yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; + }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("removes unreferenced attachment files when a thread is reverted", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); - const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; - const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; - const otherThreadAttachmentId = - "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-revert-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-revert-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Project Revert Files", - workspaceRoot: "/tmp/project-revert-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-revert-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Thread Revert Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-keep"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe( - "refs/t3/checkpoints/thread-revert-files/turn/1", - ), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-keep"), - completedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-keep"), - role: "assistant", - text: "Keep", - attachments: [ - { - type: "image", - id: keepAttachmentId, - name: "keep.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: TurnId.makeUnsafe("turn-keep"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-5"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-5"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-remove"), - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.makeUnsafe( - "refs/t3/checkpoints/thread-revert-files/turn/2", - ), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-remove"), - completedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-6"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-6"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-remove"), - role: "assistant", - text: "Remove", - attachments: [ - { - type: "image", - id: removeAttachmentId, - name: "remove.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: TurnId.makeUnsafe("turn-remove"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`); - const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(keepPath, Buffer.from("keep")); - fs.writeFileSync(removePath, Buffer.from("remove")); - const otherThreadPath = path.join( - stateDir, - "attachments", - `${otherThreadAttachmentId}.png`, - ); - fs.writeFileSync(otherThreadPath, Buffer.from("other")); - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), true); - assert.equal(fs.existsSync(otherThreadPath), true); - - yield* appendAndProject({ - type: "thread.reverted", - eventId: EventId.makeUnsafe("evt-revert-files-7"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-7"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), - metadata: {}, - payload: { - threadId, - turnCount: 1, - }, - }); - - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), false); - assert.equal(fs.existsSync(otherThreadPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), - ); + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); + const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; + const otherThreadAttachmentId = + "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; - it.effect("removes thread attachment directory when thread is deleted", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); - const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; - const otherThreadAttachmentId = - "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-delete-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-delete-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Project Delete Files", - workspaceRoot: "/tmp/project-delete-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-delete-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Thread Delete Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-delete-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-delete-files"), - role: "user", - text: "Delete", - attachments: [ - { - type: "image", - id: attachmentId, - name: "delete.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`); - const otherThreadAttachmentPath = path.join( - stateDir, - "attachments", - `${otherThreadAttachmentId}.png`, - ); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); - fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread")); - assert.equal(fs.existsSync(threadAttachmentPath), true); - assert.equal(fs.existsSync(otherThreadAttachmentPath), true); - - yield* appendAndProject({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-delete-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), - metadata: {}, - payload: { - threadId, - deletedAt: now, + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-revert-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-revert-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Project Revert Files", + workspaceRoot: "/tmp/project-revert-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-revert-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Thread Revert Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-keep"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-keep"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-keep"), + role: "assistant", + text: "Keep", + attachments: [ + { + type: "image", + id: keepAttachmentId, + name: "keep.png", + mimeType: "image/png", + sizeBytes: 5, }, - }); - - assert.equal(fs.existsSync(threadAttachmentPath), false); - assert.equal(fs.existsSync(otherThreadAttachmentPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), - ); + ], + turnId: TurnId.makeUnsafe("turn-keep"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-5"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-remove"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-remove"), + completedAt: now, + }, + }); - it.effect("ignores unsafe thread ids for attachment cleanup paths", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")), - ).pipe( - Effect.flatMap((stateDir) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const attachmentsRootDir = path.join(stateDir, "attachments"); - const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); - const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); - fs.mkdirSync(attachmentsRootDir, { recursive: true }); - fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); - fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); - - yield* eventStore.append({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe(".."), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe(".."), - deletedAt: now, + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-6"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-6"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-remove"), + role: "assistant", + text: "Remove", + attachments: [ + { + type: "image", + id: removeAttachmentId, + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, }, - }); + ], + turnId: TurnId.makeUnsafe("turn-remove"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); + const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(removePath, "remove"); + const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); + yield* fileSystem.writeFileString(otherThreadPath, "other"); + assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); - assert.equal(fs.existsSync(attachmentsRootDir), true); - assert.equal(fs.existsSync(attachmentsSentinelPath), true); - assert.equal(fs.existsSync(stateDirSentinelPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.makeUnsafe("evt-revert-files-7"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-7"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), + metadata: {}, + payload: { + threadId, + turnCount: 1, + }, + }); + + assert.isTrue(yield* exists(keepPath)); + assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); + }), ); +}); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("removes thread attachment directory when thread is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); + const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const otherThreadAttachmentId = + "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-delete-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-delete-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Project Delete Files", + workspaceRoot: "/tmp/project-delete-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-delete-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Thread Delete Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-delete-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-delete-files"), + role: "user", + text: "Delete", + attachments: [ + { + type: "image", + id: attachmentId, + name: "delete.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + const otherThreadAttachmentPath = path.join( + attachmentsDir, + `${otherThreadAttachmentId}.png`, + ); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); + yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); + assert.isTrue(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-delete-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), + metadata: {}, + payload: { + threadId, + deletedAt: now, + }, + }); + + assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + }), + ); + }, +); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-delete-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("ignores unsafe thread ids for attachment cleanup paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const now = new Date().toISOString(); + const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig; + const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); + const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); + yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentsSentinelPath, "keep-attachments-root"); + yield* fileSystem.writeFileString(stateDirSentinelPath, "keep-state-dir"); + + yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe(".."), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe(".."), + deletedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(attachmentsRootDir)); + assert.isTrue(yield* exists(attachmentsSentinelPath)); + assert.isTrue(yield* exists(stateDirSentinelPath)); + }), + ); + }, +); + +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1714,8 +1682,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const { dbPath } = yield* ServerConfig; const persistenceLayer = makeSqlitePersistenceLive(dbPath); const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), @@ -1830,11 +1797,14 @@ it.effect("restores pending turn-start metadata across projection pipeline resta startedAt: turnStartedAt, }, ]); - - fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe( Effect.provide( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd()), NodeServices.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-restart-", + }), + NodeServices.layer, + ), ), ), ); @@ -1845,7 +1815,11 @@ const engineLayer = it.layer( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-engine-dispatch-", + }), + ), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d46764cc8c..0651dab646 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -243,7 +243,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid const fileSystem = yield* Effect.service(FileSystem.FileSystem); const path = yield* Effect.service(Path.Path); - const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments"); + const attachmentsRootDir = serverConfig.attachmentsDir; yield* Effect.forEach( sideEffects.deletedThreadIds, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ef4ef4ba69..54e1e759b6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -16,7 +16,7 @@ import { import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ServerConfig } from "../../config.ts"; +import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../../git/Errors.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -42,6 +42,9 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => + Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); + async function waitFor( predicate: () => boolean | Promise, timeoutMs = 2000, @@ -68,6 +71,7 @@ describe("ProviderCommandReactor", () => { > | null = null; let scope: Scope.Closeable | null = null; const createdStateDirs = new Set(); + const createdBaseDirs = new Set(); afterEach(async () => { if (scope) { @@ -82,16 +86,22 @@ describe("ProviderCommandReactor", () => { fs.rmSync(stateDir, { recursive: true, force: true }); } createdStateDirs.clear(); + for (const baseDir of createdBaseDirs) { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + createdBaseDirs.clear(); }); async function createHarness(input?: { - readonly stateDir?: string; + readonly baseDir?: string; readonly threadModel?: string; }) { const now = new Date().toISOString(); - const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); - const threadModel = input?.threadModel ?? "gpt-5-codex"; + const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + createdBaseDirs.add(baseDir); + const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); + const threadModel = input?.threadModel ?? "gpt-5-codex"; const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -214,7 +224,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..0721d0d9f8 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -27,10 +27,10 @@ export const expandHomePath = Effect.fn(function* (input: string) { return input; }); -export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { +export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(OS.homedir(), ".t3", "userdata"); + return join(OS.homedir(), ".t3"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 33f99482d9..c430e79efb 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -49,9 +49,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge( makeRuntimeSqliteLayer({ filename: ":memory:" }), ); -export const layerConfig = Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const { join } = yield* Path.Path; - const dbPath = join(stateDir, "state.sqlite"); - return makeSqlitePersistenceLive(dbPath); -}).pipe(Layer.unwrap); +export const layerConfig = Layer.unwrap( + Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a3f67651d8..c54308a112 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -133,7 +133,7 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; - readonly stateDir?: string; + readonly baseDir?: string; }) { const query = new FakeClaudeQuery(); let createInput: @@ -165,7 +165,7 @@ function makeHarness(config?: { Layer.provideMerge( ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", - config?.stateDir ?? "/tmp", + config?.baseDir ?? "/tmp", ), ), Layer.provideMerge(NodeServices.layer), @@ -516,15 +516,15 @@ describe("ClaudeAdapterLive", () => { }); it.effect("embeds image attachments in Claude user messages", () => { - const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); + const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); const harness = makeHarness({ cwd: "/tmp/project-claude-attachments", - stateDir, + baseDir, }); return Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => - rmSync(stateDir, { + rmSync(baseDir, { recursive: true, force: true, }), @@ -532,6 +532,7 @@ describe("ClaudeAdapterLive", () => { ); const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; const attachment = { type: "image" as const, @@ -540,7 +541,7 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment)); + const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment)); mkdirSync(path.dirname(attachmentPath), { recursive: true }); writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 0b3a20aebe..b42550b580 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -490,7 +490,7 @@ function buildUserMessageEffect( input: ProviderSendTurnInput, dependencies: { readonly fileSystem: FileSystem.FileSystem; - readonly stateDir: string; + readonly attachmentsDir: string; }, ): Effect.Effect { return Effect.gen(function* () { @@ -515,7 +515,7 @@ function buildUserMessageEffect( } const attachmentPath = resolveAttachmentPath({ - stateDir: dependencies.stateDir, + attachmentsDir: dependencies.attachmentsDir, attachment, }); if (!attachmentPath) { @@ -2791,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const message = yield* buildUserMessageEffect(input, { fileSystem, - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 13be631b58..b4d5ee98cc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1334,7 +1334,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!attachmentPath) { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index b280e21f0f..8a3eb95bd0 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1416,7 +1416,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => : record.reasoningEffort; const attachments = yield* Effect.forEach(input.attachments ?? [], (attachment) => { const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!attachmentPath) { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 3b489c1d6e..b44cdd8830 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -51,9 +49,7 @@ export function makeServerProviderLayer(): Layer.Layer< SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService > { return Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const providerLogsDir = path.join(stateDir, "logs", "provider"); - const providerEventLogPath = path.join(providerLogsDir, "events.log"); + const { providerEventLogPath } = yield* ServerConfig; const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "native", }); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index de6b27f429..1b90babaad 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import path from "node:path"; import { Effect, Logger } from "effect"; import * as Layer from "effect/Layer"; @@ -7,16 +6,13 @@ import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { - const config = yield* ServerConfig; - - const logDir = path.join(config.stateDir, "logs"); - const logPath = path.join(logDir, "server.log"); + const { logsDir, serverLogPath } = yield* ServerConfig; yield* Effect.sync(() => { - fs.mkdirSync(logDir, { recursive: true }); + fs.mkdirSync(logsDir, { recursive: true }); }); - const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath)); + const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); return Logger.layer([Logger.defaultLogger, fileLogger], { mergeWithExisting: false, diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index cf6ac72178..d7784eb88b 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -9,6 +9,10 @@ const CodexAuthJsonSchema = Schema.Struct({ }), }); +const ClaudeJsonSchema = Schema.Struct({ + userID: Schema.String, +}); + class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { message: Schema.String, cause: Schema.optional(Schema.Defect), @@ -37,12 +41,23 @@ const getCodexAccountId = Effect.gen(function* () { return authJson.tokens.account_id; }); -const upsertAnonymousId = Effect.gen(function* () { +const getClaudeUserId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; - const anonymousIdPath = path.join(serverConfig.stateDir, "anonymous-id"); + const claudeJsonPath = path.join(homedir(), ".claude.json"); + const claudeJson = yield* Effect.flatMap( + fileSystem.readFileString(claudeJsonPath), + Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), + ); + + return claudeJson.userID; +}); + +const upsertAnonymousId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { anonymousIdPath } = yield* ServerConfig; + const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( Effect.catch(() => Effect.gen(function* () { @@ -59,7 +74,8 @@ const upsertAnonymousId = Effect.gen(function* () { /** * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. * 1. ~/.codex/auth.json tokens.account_id - * 2. ~/.t3/telemetry/anonymous-id + * 2. ~/.claude.json userID + * 3. ~/.t3/telemetry/anonymous-id */ export const getTelemetryIdentifier = Effect.gen(function* () { const codexAccountId = yield* Effect.result(getCodexAccountId); @@ -67,6 +83,11 @@ export const getTelemetryIdentifier = Effect.gen(function* () { return yield* hash(codexAccountId.success); } + const claudeUserId = yield* Effect.result(getClaudeUserId); + if (claudeUserId._tag === "Success") { + return yield* hash(claudeUserId.success); + } + const anonymousId = yield* Effect.result(upsertAnonymousId); if (anonymousId._tag === "Success") { return yield* hash(anonymousId.success); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index adf7564ce4..5fe0795ce4 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -1,7 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer } from "effect"; +import { ConfigProvider, Effect, Layer } from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -37,13 +37,10 @@ interface RecordedBatchBody { it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const stateDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-telemetry-flush-", - }); - const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-base-", + }); const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); const configLayer = ConfigProvider.layer( diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index f84e7b5930..8c71834e9e 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -13,7 +13,7 @@ import { type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; -import { Effect, Encoding, Layer, Path, Schema } from "effect"; +import { Effect, Encoding, Layer, Schema } from "effect"; import { createLogger } from "../../logger"; import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; @@ -1172,13 +1172,11 @@ export class TerminalManagerRuntime extends EventEmitter export const TerminalManagerLive = Layer.effect( TerminalManager, Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const { join } = yield* Path.Path; - const logsDir = join(stateDir, "logs", "terminals"); + const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; const runtime = yield* Effect.acquireRelease( - Effect.sync(() => new TerminalManagerRuntime({ logsDir, ptyAdapter })), + Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })), (r) => Effect.sync(() => r.dispose()), ); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index b8c22d801a..0432775fcf 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -8,7 +8,7 @@ import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effec import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; -import { ServerConfig, type ServerConfigShape } from "./config"; +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { getProviderCapabilities } from "./provider/Services/ProviderAdapter.ts"; @@ -452,6 +452,16 @@ function expectAvailableEditors(value: unknown): void { } } +function ensureParentDir(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { + return Effect.runSync( + deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)), + ); +} + describe("WebSocket Server", () => { let server: Http.Server | null = null; let serverScope: Scope.Closeable | null = null; @@ -475,7 +485,7 @@ describe("WebSocket Server", () => { logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; - stateDir?: string; + baseDir?: string; staticDir?: string; providerLayer?: Layer.Layer; providerHealth?: ProviderHealthShape; @@ -489,7 +499,9 @@ describe("WebSocket Server", () => { throw new Error("Test server is already running"); } - const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); + const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); + const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; + const derivedPaths = deriveServerPathsSync(baseDir, devUrl); const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); @@ -503,10 +515,10 @@ describe("WebSocket Server", () => { port: 0, host: undefined, cwd: options.cwd ?? "/test/project", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, + baseDir, + ...derivedPaths, staticDir: options.staticDir, - devUrl: options.devUrl ? new URL(options.devUrl) : undefined, + devUrl, noBrowser: true, authToken: options.authToken, autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, @@ -591,12 +603,13 @@ describe("WebSocket Server", () => { }); it("serves persisted attachments from stateDir", async () => { - const stateDir = makeTempDir("t3code-state-attachments-"); - const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); + const baseDir = makeTempDir("t3code-state-attachments-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); - server = await createTestServer({ cwd: "/test/project", stateDir }); + server = await createTestServer({ cwd: "/test/project", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -609,10 +622,10 @@ describe("WebSocket Server", () => { }); it("serves persisted attachments for URL-encoded paths", async () => { - const stateDir = makeTempDir("t3code-state-attachments-encoded-"); + const baseDir = makeTempDir("t3code-state-attachments-encoded-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); const attachmentPath = path.join( - stateDir, - "attachments", + attachmentsDir, "thread%20folder", "message%20folder", "file%20name.png", @@ -620,7 +633,7 @@ describe("WebSocket Server", () => { fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); - server = await createTestServer({ cwd: "/test/project", stateDir }); + server = await createTestServer({ cwd: "/test/project", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -635,11 +648,11 @@ describe("WebSocket Server", () => { }); it("serves static index for root path", async () => { - const stateDir = makeTempDir("t3code-state-static-root-"); + const baseDir = makeTempDir("t3code-state-static-root-"); const staticDir = makeTempDir("t3code-static-root-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -650,11 +663,11 @@ describe("WebSocket Server", () => { }); it("rejects static path traversal attempts", async () => { - const stateDir = makeTempDir("t3code-state-static-traversal-"); + const baseDir = makeTempDir("t3code-state-static-traversal-"); const staticDir = makeTempDir("t3code-static-traversal-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -732,15 +745,16 @@ describe("WebSocket Server", () => { }); it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); - const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( + const baseDir = makeTempDir("t3code-state-bootstrap-existing-"); + const { dbPath } = deriveServerPathsSync(baseDir, undefined); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( Layer.provide(NodeServices.layer), ); const cwd = "/test/bootstrap-existing"; server = await createTestServer({ cwd, - stateDir, + baseDir, persistenceLayer, autoBootstrapProjectFromCwd: true, }); @@ -763,7 +777,7 @@ describe("WebSocket Server", () => { server = await createTestServer({ cwd, - stateDir, + baseDir, persistenceLayer, autoBootstrapProjectFromCwd: true, }); @@ -812,11 +826,12 @@ describe("WebSocket Server", () => { }); it("responds to server.getConfig", async () => { - const stateDir = makeTempDir("t3code-state-get-config-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-get-config-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "[]", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -837,11 +852,11 @@ describe("WebSocket Server", () => { }); it("bootstraps default keybindings file when missing", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); expect(fs.existsSync(keybindingsPath)).toBe(false); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -867,11 +882,12 @@ describe("WebSocket Server", () => { }); it("falls back to defaults and reports malformed keybindings config issues", async () => { - const stateDir = makeTempDir("t3code-state-malformed-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-malformed-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -898,8 +914,9 @@ describe("WebSocket Server", () => { }); it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { - const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([ @@ -910,7 +927,7 @@ describe("WebSocket Server", () => { "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -949,11 +966,12 @@ describe("WebSocket Server", () => { }); it("pushes server.configUpdated issues when keybindings file changes", async () => { - const stateDir = makeTempDir("t3code-state-keybindings-watch-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-keybindings-watch-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "[]", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1009,8 +1027,9 @@ describe("WebSocket Server", () => { }); it("reads keybindings from the configured state directory", async () => { - const stateDir = makeTempDir("t3code-state-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([ @@ -1020,7 +1039,7 @@ describe("WebSocket Server", () => { ]), "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1044,15 +1063,16 @@ describe("WebSocket Server", () => { }); it("upserts keybinding rules and updates cached server config", async () => { - const stateDir = makeTempDir("t3code-state-upsert-keybinding-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-upsert-keybinding-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e5554606d3..a113a17584 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -382,7 +382,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }; const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment: persistedAttachment, }); if (!attachmentPath) { @@ -452,11 +452,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); const filePath = isIdLookup ? resolveAttachmentPathById({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachmentId: normalizedRelativePath, }) : resolveAttachmentRelativePath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, relativePath: normalizedRelativePath, }); if (!filePath) { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index ab05a221f0..4ce85e7654 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -6,12 +6,30 @@ import { DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, getAppSettingsSnapshot, + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, + patchCustomModels, patchGitTextGenerationModelOverrides, resolveAppModelSelection, resolveGitTextGenerationModelSelection, } from "./appSettings"; +/** Empty custom models for all providers — test helper */ +const EMPTY_CUSTOM_MODELS = { + codex: [] as readonly string[], + copilot: [] as readonly string[], + claudeAgent: [] as readonly string[], + cursor: [] as readonly string[], + opencode: [] as readonly string[], + geminiCli: [] as readonly string[], + amp: [] as readonly string[], + kilo: [] as readonly string[], +} as const; + const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const originalWindow = globalThis.window; @@ -128,13 +146,35 @@ describe("getAppModelOptions", () => { describe("resolveAppModelSelection", () => { it("preserves saved custom model slugs instead of falling back to the default", () => { - expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe( - "galapagos-alpha", - ); + expect( + resolveAppModelSelection( + "codex", + { ...EMPTY_CUSTOM_MODELS, codex: ["galapagos-alpha"] }, + "galapagos-alpha", + ), + ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "")).toBe("gpt-5.4"); + }); + + it("resolves display names through the shared resolver", () => { + expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "GPT-5.3 Codex")).toBe( + "gpt-5.3-codex", + ); + }); + + it("resolves aliases through the shared resolver", () => { + expect(resolveAppModelSelection("claudeAgent", EMPTY_CUSTOM_MODELS, "sonnet")).toBe( + "claude-sonnet-4-6", + ); + }); + + it("resolves transient selected custom models included in app model options", () => { + expect(resolveAppModelSelection("codex", EMPTY_CUSTOM_MODELS, "custom/selected-model")).toBe( + "custom/selected-model", + ); }); }); @@ -214,6 +254,115 @@ describe("provider-specific custom models", () => { }); }); +describe("provider-indexed custom model settings", () => { + const settings = { + customCodexModels: ["custom/codex-model"], + customClaudeModels: ["claude/custom-opus"], + customCopilotModels: [], + customCursorModels: [], + customOpencodeModels: [], + customGeminiCliModels: [], + customAmpModels: [], + customKiloModels: [], + } as const; + + it("exports one provider config per provider", () => { + expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", + ]); + }); + + it("reads custom models for each provider", () => { + expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); + expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); + }); + + it("reads default custom models for each provider", () => { + const defaults = { + customCodexModels: ["default/codex-model"], + customClaudeModels: ["claude/default-opus"], + customCopilotModels: [], + customCursorModels: [], + customOpencodeModels: [], + customGeminiCliModels: [], + customAmpModels: [], + customKiloModels: [], + } as const; + + expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); + expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ + "claude/default-opus", + ]); + }); + + it("patches custom models for codex", () => { + expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ + customCodexModels: ["custom/codex-model"], + }); + }); + + it("patches custom models for claude", () => { + expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ + customClaudeModels: ["claude/custom-opus"], + }); + }); + + it("builds a complete provider-indexed custom model record", () => { + expect(getCustomModelsByProvider(settings)).toEqual({ + codex: ["custom/codex-model"], + copilot: [], + claudeAgent: ["claude/custom-opus"], + cursor: [], + opencode: [], + geminiCli: [], + amp: [], + kilo: [], + }); + }); + + it("builds provider-indexed model options including custom models", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); + + expect( + modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), + ).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), + ).toBe(true); + }); + + it("normalizes and deduplicates custom model options per provider", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider({ + customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], + customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], + customCopilotModels: [], + customCursorModels: [], + customOpencodeModels: [], + customGeminiCliModels: [], + customAmpModels: [], + customKiloModels: [], + }); + + expect( + modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), + ).toHaveLength(1); + expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), + ).toHaveLength(1); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), + ).toBe(true); + }); +}); + describe("AppSettingsSchema", () => { it("fills decoding defaults for persisted settings that predate newer keys", () => { const decode = Schema.decodeUnknownSync(Schema.fromJsonString(AppSettingsSchema)); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index a2ca6a2cf3..1389b857d1 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -4,7 +4,12 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, type ProviderKind, } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { + getDefaultModel, + getModelOptions, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; @@ -35,6 +40,24 @@ const AppProviderLogoAppearanceSchema = Schema.Literals(["original", "grayscale" export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +type CustomModelSettingsKey = + | "customCodexModels" + | "customCopilotModels" + | "customClaudeModels" + | "customCursorModels" + | "customOpencodeModels" + | "customGeminiCliModels" + | "customAmpModels" + | "customKiloModels"; +export type ProviderCustomModelConfig = { + provider: ProviderKind; + settingsKey: CustomModelSettingsKey; + defaultSettingsKey: CustomModelSettingsKey; + title: string; + description: string; + placeholder: string; + example: string; +}; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), @@ -140,6 +163,81 @@ type ProviderCustomModelSettings = Pick< >; const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { + codex: { + provider: "codex", + settingsKey: "customCodexModels", + defaultSettingsKey: "customCodexModels", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", + }, + copilot: { + provider: "copilot", + settingsKey: "customCopilotModels", + defaultSettingsKey: "customCopilotModels", + title: "Copilot", + description: "Save additional Copilot model slugs for the picker and `/model` command.", + placeholder: "your-copilot-model-slug", + example: "gpt-4o-copilot", + }, + claudeAgent: { + provider: "claudeAgent", + settingsKey: "customClaudeModels", + defaultSettingsKey: "customClaudeModels", + title: "Claude", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, + cursor: { + provider: "cursor", + settingsKey: "customCursorModels", + defaultSettingsKey: "customCursorModels", + title: "Cursor", + description: "Save additional Cursor model slugs for the picker and `/model` command.", + placeholder: "your-cursor-model-slug", + example: "cursor-fast", + }, + opencode: { + provider: "opencode", + settingsKey: "customOpencodeModels", + defaultSettingsKey: "customOpencodeModels", + title: "OpenCode", + description: "Save additional OpenCode model slugs for the picker and `/model` command.", + placeholder: "your-opencode-model-slug", + example: "opencode-pro", + }, + geminiCli: { + provider: "geminiCli", + settingsKey: "customGeminiCliModels", + defaultSettingsKey: "customGeminiCliModels", + title: "Gemini CLI", + description: "Save additional Gemini CLI model slugs for the picker and `/model` command.", + placeholder: "your-gemini-model-slug", + example: "gemini-2.0-ultra", + }, + amp: { + provider: "amp", + settingsKey: "customAmpModels", + defaultSettingsKey: "customAmpModels", + title: "Amp", + description: "Save additional Amp model slugs for the picker and `/model` command.", + placeholder: "your-amp-model-slug", + example: "amp-pro", + }, + kilo: { + provider: "kilo", + settingsKey: "customKiloModels", + defaultSettingsKey: "customKiloModels", + title: "Kilo", + description: "Save additional Kilo model slugs for the picker and `/model` command.", + placeholder: "your-kilo-model-slug", + example: "kilo-advanced", + }, +}; +export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, @@ -276,6 +374,28 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { }; } +export function getDefaultCustomModelsForProvider( + defaults: Pick, + provider: ProviderKind, +): readonly string[] { + return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; +} + +export function getCustomModelsByProvider( + settings: Pick, +): Record { + return { + codex: getCustomModelsForProvider(settings, "codex"), + copilot: getCustomModelsForProvider(settings, "copilot"), + claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), + cursor: getCustomModelsForProvider(settings, "cursor"), + opencode: getCustomModelsForProvider(settings, "opencode"), + geminiCli: getCustomModelsForProvider(settings, "geminiCli"), + amp: getCustomModelsForProvider(settings, "amp"), + kilo: getCustomModelsForProvider(settings, "kilo"), + }; +} + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -287,6 +407,7 @@ export function getAppModelOptions( isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); + const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { @@ -302,7 +423,14 @@ export function getAppModelOptions( } const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { + const selectedModelMatchesExistingName = + typeof trimmedSelectedModel === "string" && + options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); + if ( + normalizedSelectedModel && + !seen.has(normalizedSelectedModel) && + !selectedModelMatchesExistingName + ) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, @@ -315,34 +443,28 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: readonly string[], + customModels: Record, selectedModel: string | null | undefined, ): string { - const options = getAppModelOptions(provider, customModels, selectedModel); - const trimmedSelectedModel = selectedModel?.trim(); - if (trimmedSelectedModel) { - const direct = options.find((option) => option.slug === trimmedSelectedModel); - if (direct) { - return direct.slug; - } - - const byName = options.find( - (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), - ); - if (byName) { - return byName.slug; - } - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (!normalizedSelectedModel) { - return getDefaultModel(provider); - } + const customModelsForProvider = customModels[provider]; + const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); + return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); +} - return ( - options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? - getDefaultModel(provider) - ); +export function getCustomModelOptionsByProvider( + settings: Pick, +): Record> { + const customModelsByProvider = getCustomModelsByProvider(settings); + return { + codex: getAppModelOptions("codex", customModelsByProvider.codex), + copilot: getAppModelOptions("copilot", customModelsByProvider.copilot), + claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), + cursor: getAppModelOptions("cursor", customModelsByProvider.cursor), + opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), + geminiCli: getAppModelOptions("geminiCli", customModelsByProvider.geminiCli), + amp: getAppModelOptions("amp", customModelsByProvider.amp), + kilo: getAppModelOptions("kilo", customModelsByProvider.kilo), + }; } export function resolveGitTextGenerationModelSelection( @@ -353,18 +475,18 @@ export function resolveGitTextGenerationModelSelection( >, activeModel: string | null | undefined, ): string { - const customModels = getCustomModelsForProvider(settings, provider); + const customModelsByProvider = getCustomModelsByProvider(settings); const overrideModel = getGitTextGenerationModelOverride(settings, provider); if (overrideModel) { - return resolveAppModelSelection(provider, customModels, overrideModel); + return resolveAppModelSelection(provider, customModelsByProvider, overrideModel); } const normalizedActiveModel = normalizeModelSlug(activeModel, provider); if (normalizedActiveModel) { - return resolveAppModelSelection(provider, customModels, normalizedActiveModel); + return resolveAppModelSelection(provider, customModelsByProvider, normalizedActiveModel); } return resolveAppModelSelection( provider, - customModels, + customModelsByProvider, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider], ); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..48c627747d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, }); useStore.setState({ projects: [], @@ -1279,6 +1281,198 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("snapshots sticky codex settings into a new draft thread", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, + targetText: "sticky codex traits test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + model: "gpt-5.3-codex", + provider: "codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hydrates the provider alongside a sticky claude model", async () => { + useComposerDraftStore.setState({ + stickyModel: "claude-opus-4-6", + stickyModelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, + targetText: "sticky claude model test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new sticky claude draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + provider: "claudeAgent", + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to defaults when no sticky composer settings exist", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-default-codex-traits-test" as MessageId, + targetText: "default codex traits test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + } finally { + await mounted.cleanup(); + } + }); + + it("prefers draft state over sticky composer settings and defaults", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, + targetText: "draft codex traits precedence test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const threadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a sticky draft thread UUID.", + ); + const threadId = threadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.3-codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + + useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); + useComposerDraftStore.getState().setModelOptions(threadId, { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }); + + await newThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => path === threadPath, + "New-thread should reuse the existing project draft thread.", + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.4", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b509bf33dd..8150aee236 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,18 +28,11 @@ import { getClaudeCodeEffortOptions, getDefaultClaudeCodeEffort, getDefaultModel, - getDefaultReasoningEffort, getCursorModelCapabilities, - getReasoningEffortOptions, - isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, parseCursorModelSelection, resolveCursorModelFromSelection, - resolveReasoningEffortForProvider, resolveModelSlugForProvider, - supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -137,7 +130,11 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { + getCustomModelsByProvider, + resolveAppModelSelection, + useAppSettings, +} from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -168,13 +165,16 @@ import { } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; -import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CursorTraitsPicker } from "./chat/CursorTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./chat/composerProviderRegistry"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -261,6 +261,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -615,91 +616,31 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? activeThread?.provider ?? "codex"; - const customModelsByProvider = useMemo( - () => ({ - codex: settings.customCodexModels, - copilot: settings.customCopilotModels, - claudeAgent: settings.customClaudeModels, - cursor: settings.customCursorModels, - opencode: settings.customOpencodeModels, - geminiCli: settings.customGeminiCliModels, - amp: settings.customAmpModels ?? [], - kilo: settings.customKiloModels ?? [], - }), - [ - settings.customClaudeModels, - settings.customCodexModels, - settings.customCopilotModels, - settings.customCursorModels, - settings.customOpencodeModels, - settings.customGeminiCliModels, - settings.customAmpModels, - settings.customKiloModels, - ], - ); const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; + const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } - return resolveAppModelSelection( - selectedProvider, - customModelsForSelectedProvider, - draftModel, - ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); + }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); const draftModelOptions = composerDraft.modelOptions; - const selectedCodexEffort = - selectedProvider === "codex" - ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ?? - getDefaultReasoningEffort("codex")) - : null; - const selectedClaudeReasoningOptions = - selectedProvider === "claudeAgent" - ? getReasoningEffortOptions("claudeAgent", selectedModel) - : ([] as const); - const selectedClaudeBaseEffort = - selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0 - ? (() => { - const draftEffort = resolveReasoningEffortForProvider( - "claudeAgent", - draftModelOptions?.claudeAgent?.effort, - ); - if ( - draftEffort && - draftEffort !== "ultrathink" && - selectedClaudeReasoningOptions.includes(draftEffort) - ) { - return draftEffort; - } - const defaultEffort = getDefaultReasoningEffort("claudeAgent"); - return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null; - })() - : null; - const isClaudeUltrathink = - selectedProvider === "claudeAgent" && - supportsClaudeUltrathinkKeyword(selectedModel) && - isClaudeUltrathinkPrompt(prompt); - const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort; - const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider === "codex") { - const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); - return codexOptions ? { codex: codexOptions } : undefined; - } - if (selectedProvider === "claudeAgent") { - const claudeOptions = normalizeClaudeModelOptions( - selectedModel, - draftModelOptions?.claudeAgent, - ); - return claudeOptions ? { claudeAgent: claudeOptions } : undefined; - } - return undefined; - }, [draftModelOptions, selectedModel, selectedProvider]); + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + prompt, + modelOptions: draftModelOptions, + }), + [draftModelOptions, prompt, selectedModel, selectedProvider], + ); + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const selectedCursorModel = useMemo( () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), [selectedModel, selectedProvider], @@ -1245,13 +1186,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; - const activeProvider = resolveProviderHealthBannerProvider({ - sessionProvider: activeThread?.session?.provider ?? null, - selectedProvider, - }); const activeProviderStatus = useMemo( - () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, - [activeProvider, providerStatuses], + () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, + [selectedProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; @@ -3244,20 +3181,20 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel( - activeThread.id, - resolveAppModelSelection(provider, customModelsByProvider[provider], model), - ); + setComposerDraftModel(activeThread.id, resolvedModel); + setStickyComposerModel(resolvedModel); scheduleComposerFocus(); }, [ activeThread, - customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setStickyComposerModel, + customModelsByProvider, ], ); const setPromptFromTraits = useCallback( @@ -3318,6 +3255,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [onProviderModelSelect, selectedModel, selectedProvider], ); + const providerTraitsMenuContent = renderProviderTraitsMenuContent({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); + const providerTraitsPicker = renderProviderTraitsPicker({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3733,7 +3682,7 @@ export default function ChatView({ threadId }: ChatViewProps) {