diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fc29dd94ab..1be60f28bb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", + "acorn": "8.16.0", "cross-env": "^10.1.0", "electron-builder": "26.15.6", "tailwindcss": "^4.0.0", diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs new file mode 100644 index 0000000000..34d39fb8e3 --- /dev/null +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -0,0 +1,147 @@ +import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeTimers from "node:timers"; +import * as NodeURL from "node:url"; +import * as NodeVM from "node:vm"; +import { parse } from "acorn"; + +const expectedDesktopBridgeApis = [ + "getClientPlatform", + "getLocalEnvironmentBootstraps", + "pickFolder", +]; +const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; +const preloadExecutionTimeoutMs = 1_000; +const desktopPackage = JSON.parse( + NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const electronVersion = desktopPackage.dependencies.electron; + +const isSyntaxNode = (value) => + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; + +const inspectBundle = (source) => { + const runtimeImports = []; + const visit = (node) => { + if (node.type === "ImportExpression") { + throw new Error("Desktop preload bundle contains a dynamic import() call"); + } + + if (node.type === "CallExpression" && node.callee.type === "Identifier") { + if (node.callee.name === "require") { + const [argument] = node.arguments; + if (node.arguments.length !== 1 || argument?.type !== "Literal") { + throw new Error("Desktop preload bundle contains a dynamic require() call"); + } + if (typeof argument.value !== "string") { + throw new Error("Desktop preload bundle contains a dynamic require() call"); + } + runtimeImports.push(argument.value); + } + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + if (isSyntaxNode(item)) visit(item); + } + } else if (isSyntaxNode(child)) { + visit(child); + } + } + }; + + visit(parse(source, { ecmaVersion: "latest", sourceType: "script" })); + return runtimeImports; +}; + +const createSandboxModules = (exposedGlobals) => { + const ipcRenderer = { + invoke: () => Promise.resolve(undefined), + on: () => undefined, + removeListener: () => undefined, + sendSync: () => undefined, + }; + const electron = { + contextBridge: { + exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), + }, + ipcRenderer, + }; + + return new Map([ + ["electron", electron], + ["electron/common", electron], + ["electron/renderer", electron], + ["events", NodeEvents.default], + ["node:events", NodeEvents.default], + ["timers", NodeTimers.default], + ["node:timers", NodeTimers.default], + ["url", NodeURL.default], + ["node:url", NodeURL.default], + ]); +}; + +const executeBundle = (source, sandboxModules) => { + const sandboxProcess = { + contextIsolated: true, + // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI verifier supplies the preload's host platform without loading Effect. + platform: process.platform, + versions: { electron: electronVersion }, + }; + const requireSandboxModule = (moduleName) => { + if (!sandboxModules.has(moduleName)) { + throw new Error( + `Unsupported sandbox module requested during preload execution: ${moduleName}`, + ); + } + return sandboxModules.get(moduleName); + }; + + NodeVM.runInNewContext( + source, + { + process: sandboxProcess, + require: requireSandboxModule, + }, + { + filename: "desktop-preload.cjs", + timeout: preloadExecutionTimeoutMs, + }, + ); +}; + +export const verifyPreloadBundle = (source) => { + const runtimeImports = inspectBundle(source); + const exposedGlobals = new Map(); + const sandboxModules = createSandboxModules(exposedGlobals); + const unsupportedImports = [...new Set(runtimeImports)] + .filter((moduleName) => !sandboxModules.has(moduleName)) + .toSorted(); + + if (unsupportedImports.length > 0) { + throw new Error( + `Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`, + ); + } + + executeBundle(source, sandboxModules); + + const desktopBridge = exposedGlobals.get("desktopBridge"); + const missingApis = expectedDesktopBridgeApis.filter( + (api) => typeof desktopBridge?.[api] !== "function", + ); + if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure"); + if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`); + + if (missingApis.length > 0) { + throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`); + } +}; + +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { + const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url); + const source = await NodeFSP.readFile(preloadUrl, "utf8"); + verifyPreloadBundle(source); +} diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs new file mode 100644 index 0000000000..a80a6d0964 --- /dev/null +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -0,0 +1,104 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { verifyPreloadBundle } from "./verify-preload-bundle.mjs"; + +const validPreload = ` + const electron = require("electron"); + const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; + electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {}); + electron.contextBridge.exposeInMainWorld("desktopBridge", { + getClientPlatform: () => process.platform, + getLocalEnvironmentBootstraps: () => [], + pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), + }); +`; + +describe("desktop preload bundle verifier", () => { + it("rejects required API names that only appear in strings", () => { + assert.throws( + () => + verifyPreloadBundle(` + "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder"; + "__clerk_internal_electron_passkeys"; + require("electron"); + `), + /missing executable APIs/, + ); + }); + + it("rejects a required API whose exposed value is not callable", () => { + assert.throws( + () => + verifyPreloadBundle( + validPreload.replace( + "getClientPlatform: () => process.platform,", + "getClientPlatform: undefined,", + ), + ), + /missing executable APIs: getClientPlatform/, + ); + }); + + it("accepts a required API exposed through a function alias", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + const readClientPlatform = () => process.platform; + ${validPreload.replace( + "getClientPlatform: () => process.platform,", + "getClientPlatform: readClientPlatform,", + )} + `), + ); + }); + + it("rejects dynamic imports with comments before the opening parenthesis", () => { + assert.throws( + () => + verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`), + /dynamic import\(\)/, + ); + }); + + it("ignores import-like text in strings", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`), + ); + }); + + it("rejects unsupported require calls with comments before the opening parenthesis", () => { + assert.throws( + () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`), + /unsupported sandbox imports: node:fs/, + ); + }); + + it("rejects unsupported optional require calls", () => { + assert.throws( + () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`), + /unsupported sandbox imports: node:fs/, + ); + }); + + it("accepts Electron sandbox module aliases", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + ${validPreload} + require("electron/common"); + require("electron/renderer"); + require("node:events"); + require("node:timers"); + require("node:url"); + `), + ); + }); + + it("ignores require-like text in strings and comments", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + ${validPreload} + const message = 'require("node:fs")'; + // require("node:path") + `), + ); + }); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index be5a76e258..36529b1215 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -58,14 +58,16 @@ function makeEnvironmentLayer( readonly devServerUrl?: string; readonly platform?: NodeJS.Platform; readonly resourcesPath?: string; + readonly appVersion?: string; + readonly processArch?: NodeJS.Architecture; }, ) { return DesktopEnvironment.layer({ dirname: options?.dirname ?? "/repo/apps/desktop/src", homeDirectory: baseDir, platform: options?.platform ?? "darwin", - processArch: "x64", - appVersion: "1.2.3", + processArch: options?.processArch ?? "x64", + appVersion: options?.appVersion ?? "1.2.3", appPath: options?.appPath ?? "/repo", isPackaged: options?.isPackaged ?? true, resourcesPath: options?.resourcesPath ?? "/missing/resources", @@ -123,7 +125,107 @@ const withHarness = ( ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +interface PackagedWslHarnessContext { + readonly baseDir: string; + readonly archivePath: string; + readonly hashPath: string; + readonly archiveHash: string; + readonly mountedAppRoot: string; + readonly mountedEntryPath: string; +} + +const withPackagedWslHarness = ( + input: { + readonly archiveHash: string; + readonly wsl: ( + context: PackagedWslHarnessContext, + ) => DesktopWslEnvironment.DesktopWslEnvironmentTestStub; + readonly forbidFallback?: string; + readonly cleanupLegacy?: Effect.Effect; + readonly forbidCleanup?: string; + }, + effect: ( + context: PackagedWslHarnessContext, + ) => Effect.Effect< + A, + E, + R | FileSystem.FileSystem | Path.Path | DesktopBackendConfiguration.DesktopBackendConfiguration + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const archivePath = path.join(baseDir, "wsl-runtime.tar.gz"); + const hashPath = `${archivePath}.sha256`; + const mountedAppRoot = "/mnt/c/app.asar.unpacked"; + const mountedEntryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(mountedEntryPath), { recursive: true }); + yield* fileSystem.writeFileString(mountedEntryPath, ""); + yield* fileSystem.writeFileString(archivePath, "archive"); + yield* fileSystem.writeFileString(hashPath, `${input.archiveHash}\n`); + + const context = { + baseDir, + archivePath, + hashPath, + archiveHash: input.archiveHash, + mountedAppRoot, + mountedEntryPath, + } satisfies PackagedWslHarnessContext; + const serverTreeLayer = input.forbidFallback + ? Layer.succeed( + DesktopWslServerTree.DesktopWslServerTree, + DesktopWslServerTree.DesktopWslServerTree.of({ + ensure: Effect.die(input.forbidFallback), + cleanupLegacy: input.forbidCleanup + ? Effect.die(input.forbidCleanup) + : (input.cleanupLegacy ?? Effect.void), + }), + ) + : DesktopWslServerTree.layerTest({ + result: { ok: true, root: path.join(baseDir, "app.asar.unpacked") }, + cleanupLegacy: input.cleanupLegacy ?? Effect.void, + }); + + return yield* effect(context).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(serverTreeLayer), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + windowsToWslPath: () => Option.some(mountedAppRoot), + getDistroIp: () => Option.some("172.27.0.99"), + ...input.wsl(context), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + describe("DesktopBackendConfiguration", () => { + it("accepts only normalized SHA-256 archive identities", () => { + assert.equal( + DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), + "a".repeat(64), + ); + assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); + }); + it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { @@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => { it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const resourcesPath = `${baseDir}/resources`; + const resourcesPath = path.join(baseDir, "resources"); const result = yield* Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -179,6 +282,7 @@ describe("DesktopBackendConfiguration", () => { DesktopWslServerTree.DesktopWslServerTree, DesktopWslServerTree.DesktopWslServerTree.of({ ensure: Effect.die("Windows primary must not extract the WSL server tree"), + cleanupLegacy: Effect.die("Windows primary must not clean the WSL server tree"), }), ), ), @@ -245,7 +349,7 @@ describe("DesktopBackendConfiguration", () => { ], windowsToWslPath: (distro) => { observedDistros.push(distro); - return Option.some("/repo/apps/server/dist/bin.mjs"); + return Option.some("/repo"); }, ensureNodePty: (distro) => { observedDistros.push(distro); @@ -275,6 +379,275 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () => { + const observedArchives: Array<{ + windowsArchivePath: string; + runtimeId: string; + sha256: string; + }> = []; + const observedNodePtyRoots: string[] = []; + let legacyCleanupCount = 0; + const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; + + return withPackagedWslHarness( + { + archiveHash: "a".repeat(64), + forbidFallback: "A valid WSL archive must not extract the Windows fallback", + cleanupLegacy: Effect.sync(() => { + legacyCleanupCount += 1; + }), + wsl: () => ({ + prepareRuntime: (_distro, archive) => { + observedArchives.push({ + windowsArchivePath: archive.windowsPath, + runtimeId: archive.runtimeId, + sha256: archive.sha256, + }); + return { ok: true, linuxAppRoot }; + }, + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ archiveHash, archivePath, baseDir }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedArchives, [ + { + windowsArchivePath: archivePath, + runtimeId: `sha256-${archiveHash}`, + sha256: archiveHash, + }, + ]); + assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); + assert.equal( + config.entryPath, + path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), + ); + assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); + assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); + assert.equal(legacyCleanupCount, 1); + assert.isTrue(Option.isNone(config.preflightFailure)); + }), + ); + }); + + it.effect("resolveWsl changes the cache id when the packaged archive changes", () => { + const firstHash = "a".repeat(64); + const secondHash = "b".repeat(64); + const observedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: firstHash, + wsl: () => ({ + prepareRuntime: (_distro, archive) => { + observedRuntimeIds.push(archive.runtimeId); + return { ok: true, linuxAppRoot: `/runtime/${archive.runtimeId}` }; + }, + ensureNodePty: () => ({ + ok: true, + nodePath: "/usr/bin/node", + resolvedPath: "/usr/bin:/bin", + }), + }), + }, + ({ hashPath, mountedAppRoot }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const first = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + yield* fileSystem.writeFileString(hashPath, secondHash); + const second = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + yield* fileSystem.writeFileString(hashPath, "not-a-sha256"); + const invalidIdentity = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); + assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); + assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); + assert.isUndefined(invalidIdentity.wslRuntimeId); + assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + }), + ); + }); + + it.effect("resolveWsl falls back to the mounted runtime when archive staging fails", () => { + const observedNodePtyRoots: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "b".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }), + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ mountedAppRoot, mountedEntryPath }) => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); + assert.equal(config.entryPath, mountedEntryPath); + assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.isUndefined(config.wslRuntimeId); + assert.isTrue(Option.isNone(config.preflightFailure)); + }), + ); + }); + + it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { + const archiveHash = "c".repeat(64); + const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; + const observedNodePtyRoots: string[] = []; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash, + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return root === stagedAppRoot + ? { ok: false, reason: "pty.node could not be loaded", fatal: true } + : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ mountedAppRoot, mountedEntryPath }) => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); + assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.equal(config.entryPath, mountedEntryPath); + assert.isUndefined(config.wslRuntimeId); + assert.isTrue(Option.isNone(config.preflightFailure)); + assert.deepEqual(invalidatedRuntimeIds, [`sha256-${archiveHash}`]); + }), + ); + }); + + it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => { + const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "d".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => ({ + ok: false, + reason: + root === stagedAppRoot + ? "unsupported CPU architecture or incompatible system libraries" + : "mounted tree is broken in some other way", + fatal: true, + }), + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isTrue(failure.fatal); + assert.include(failure.reason, "unsupported CPU architecture"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + + it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => { + const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "f".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => + root === stagedAppRoot + ? { ok: false, reason: "pty.node could not be loaded", fatal: true } + : { + ok: false, + reason: "WSL backend preflight timed out while probing for Node.js.", + fatal: false, + }, + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "timed out"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + + it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "e".repeat(64), + forbidFallback: "A transient probe failure must not extract the fallback", + forbidCleanup: "A transient probe failure must not clean the fallback tree", + wsl: () => ({ + prepareRuntime: () => ({ + ok: true, + linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", + }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: () => ({ + ok: false, + reason: "WSL backend preflight timed out while probing for Node.js.", + fatal: false, + retryLimit: 12, + }), + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "timed out"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + it.effect( "resolveWsl preserves inherited PATH with quote-sensitive values as separate args", () => @@ -289,7 +662,8 @@ describe("DesktopBackendConfiguration", () => { yield* fileSystem.writeFileString(entryPath, ""); const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node"; - const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs"; + const linuxAppRoot = "/tmp/t3 code's launch"; + const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`; const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world"; const config = yield* Effect.gen(function* () { @@ -305,7 +679,7 @@ describe("DesktopBackendConfiguration", () => { DesktopWslEnvironment.layerTest({ isAvailable: true, distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - windowsToWslPath: () => Option.some(linuxEntryPath), + windowsToWslPath: () => Option.some(linuxAppRoot), ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), getDistroIp: () => Option.some("172.27.0.99"), }), @@ -821,13 +1195,14 @@ describe("DesktopBackendConfiguration", () => { it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const resourcesPath = `${baseDir}/resources`; + const resourcesPath = path.join(baseDir, "resources"); const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`; const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`; - const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`; + const monitorPath = path.join(resourcesPath, "resource-monitor/t3-resource-monitor"); yield* fileSystem.makeDirectory( `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`, { recursive: true }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bcce731a59..4c43070b5f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -213,6 +213,7 @@ interface SharedBootstrapInput { interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; + readonly windowsEntryPath: string; readonly linuxEntryPath: string; // Absolute path to the node binary the preflight validated after the shared // remote resolver repaired PATH. The launch must use this exact path so it @@ -222,6 +223,8 @@ interface WslPreflightSuccess { // PATH captured from the same login shell after the shared resolver loaded // version managers. The launch forwards this value directly without a shell. readonly resolvedPath: string; + // Identifies the distro-local runtime cache selected from the packaged archive. + readonly runtimeId?: string; } interface WslPreflightFailure { @@ -236,18 +239,35 @@ interface WslPreflightFailure { } const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; +const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; +const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; + +export const parseWslRuntimeArchiveHash = (value: string): string | null => { + const trimmed = value.trim(); + return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; +}; + +type FailedNodePtyResult = Extract< + DesktopWslEnvironment.EnsureWslNodePtyResult, + { readonly ok: false } +>; const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { readonly distro: string | null; - readonly windowsEntryPath: string; - readonly windowsRepoRoot: string; + readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null; readonly allowBuild: boolean; }): Effect.fn.Return< WslPreflightSuccess | WslPreflightFailure, never, - DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem + | DesktopEnvironment.DesktopEnvironment + | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslServerTree.DesktopWslServerTree + | FileSystem.FileSystem > { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const fileSystem = yield* FileSystem.FileSystem; const wslAvailable = yield* wslEnv.isAvailable; @@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; } - const entryExists = yield* fileSystem - .exists(input.windowsEntryPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!entryExists) { - return { + const nodePtyOptions = { + allowBuild: input.allowBuild, + nodeEngineRange: serverPackageJson.engines.node, + }; + const failedNodePty = (result: FailedNodePtyResult) => + ({ _tag: "Failed", - reason: `missing server entry at ${input.windowsEntryPath}`, - fatal: true, - } as const; + reason: `WSL node-pty unavailable: ${result.reason}`, + fatal: result.fatal, + ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }), + }) as const; + + // The mounted server tree is the fallback runtime: the Windows-side copy the + // distro reads over /mnt. Slower to launch from, but always installed. + const resolveMountedAppRoot = Effect.gen(function* () { + const serverTree = yield* wslServerTree.ensure; + if (!serverTree.ok) { + return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const; + } + const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs"); + const entryExists = yield* fileSystem + .exists(windowsEntryPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!entryExists) { + return { + ok: false, + reason: `missing server entry at ${windowsEntryPath}`, + fatal: true, + } as const; + } + const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root); + return Option.isNone(mountedAppRoot) + ? ({ + ok: false, + reason: `wslpath conversion failed for ${serverTree.root}`, + fatal: false, + } as const) + : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); + }); + + // Set once a staged runtime has been ruled out by the probe, and carried + // through the mounted attempt: if the mounted tree works the cache is the + // broken part and gets invalidated, and if the mounted tree returns its own + // fatal verdict the cached reason is the more actionable one to report. + // A transient mounted failure is neither — it rules nothing out, so it stays + // retryable and the staged verdict waits for an attempt that can answer. + let stagedFailure: + | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } + | undefined; + + if (input.runtimeArchive !== null) { + const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); + if (runtime.ok) { + const stagedNodePty = yield* wslEnv.ensureNodePty( + runningDistro, + runtime.linuxAppRoot, + nodePtyOptions, + ); + if (stagedNodePty.ok) { + yield* wslServerTree.cleanupLegacy; + return { + _tag: "Ready", + runningDistro, + windowsEntryPath: environment.backendEntryPath, + linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, + nodePath: stagedNodePty.nodePath, + resolvedPath: stagedNodePty.resolvedPath, + runtimeId: input.runtimeArchive.runtimeId, + } as const; + } + // A transport failure says nothing about the staged tree, so it is + // retried against the same cache rather than spending a second probe on + // the mounted tree and risking a needless reinstall. + if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); + yield* Effect.logWarning( + "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", + { reason: stagedNodePty.reason }, + ); + stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; + } else { + yield* Effect.logWarning( + "Could not stage the WSL runtime; launching from the mounted server tree instead.", + { reason: runtime.reason }, + ); + } } - const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath); - if (Option.isNone(linuxEntry)) { - return { - _tag: "Failed", - reason: `wslpath conversion failed for ${input.windowsEntryPath}`, - fatal: false, - } as const; + const mounted = yield* resolveMountedAppRoot; + if (!mounted.ok) { + return stagedFailure && mounted.fatal + ? failedNodePty(stagedFailure.nodePty) + : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); } - const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, { - allowBuild: input.allowBuild, - nodeEngineRange: serverPackageJson.engines.node, - }); + const nodePtyResult = yield* wslEnv.ensureNodePty( + runningDistro, + mounted.linuxAppRoot, + nodePtyOptions, + ); if (!nodePtyResult.ok) { - return { - _tag: "Failed", - reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, - fatal: nodePtyResult.fatal, - ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }), - } as const; + // Substituting the staged verdict for a transient mounted failure would + // turn a retryable failure into a fatal one, ending the WSL attempt (and, + // in wsl-only mode, persisting Windows) before the slow /mnt path had a + // chance to answer and clear the bad cache. + return failedNodePty( + stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, + ); + } + + // The mounted tree runs what the cache could not, so the cache is the broken + // copy: revoke its ready marker so the next launch reinstalls it instead of + // reusing a tree that has already been proven unloadable. + if (stagedFailure) { + yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); } return { _tag: "Ready", runningDistro, - linuxEntryPath: linuxEntry.value, + windowsEntryPath: mounted.windowsEntryPath, + linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, nodePath: nodePtyResult.nodePath, resolvedPath: nodePtyResult.resolvedPath, } as const; @@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; + const fileSystem = yield* FileSystem.FileSystem; // Bind to 0.0.0.0 inside WSL so the backend is reachable both via // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 @@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...buildObservabilityFragment(input.observabilitySettings), }; - // In packaged builds the server tree ships inside resources/server.asar — - // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE - // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which - // can't read an asar, so materialize (or reuse) the extracted copy of the - // sidecar before preflighting. In dev the server tree is the real checkout - // directory and ensure returns it unchanged. - const serverTree = yield* wslServerTree.ensure; - const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot; - const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); - - const preflight = serverTree.ok - ? yield* runWslPreflight({ - distro: input.distro, - windowsEntryPath: wslEntryPath, - windowsRepoRoot: wslAppRoot, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. - allowBuild: !environment.isPackaged, - }) - : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const); + // The archive is the primary packaged WSL path: it installs directly into + // the distro's ext4 filesystem. The server.asar extraction service is only + // consulted lazily if the archive is unavailable or cannot be staged. + const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); + const archiveHashPath = environment.path.join( + environment.resourcesPath, + WSL_RUNTIME_ARCHIVE_HASH_NAME, + ); + + const hasArchive = environment.isPackaged + ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false)) + : false; + const archiveHash = hasArchive + ? yield* fileSystem.readFileString(archiveHashPath).pipe( + Effect.map(parseWslRuntimeArchiveHash), + Effect.orElseSucceed(() => null), + ) + : null; + if (hasArchive && archiveHash === null) { + yield* Effect.logWarning( + "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.", + { hashPath: archiveHashPath }, + ); + } + + const preflight = yield* runWslPreflight({ + distro: input.distro, + runtimeArchive: + archiveHash === null + ? null + : { + windowsPath: archivePath, + // The verified archive bytes are the cache identity. Release builds + // embed the release version and pnpm install metadata, so the + // archive changes on every update even when application logic does + // not. Later launches of that update still reuse this directory. + runtimeId: `sha256-${archiveHash}`, + sha256: archiveHash, + }, + // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and + // attached to the Windows artifact — see build-desktop-artifact.ts), so the + // WSL backend never needs a compiler, node-gyp, or network on first launch. + // Compiling from source is a dev-only convenience: a checkout has no shipped + // prebuilt, and developers have the toolchain. In packaged builds we instead + // surface a clear diagnostic if the prebuilt can't load (unsupported + // arch/distro), rather than silently dropping into a fragile runtime build. + allowBuild: !environment.isPackaged, + }); // Every operation after preflight uses the same concrete distro. In // default-tracking mode this closes the race where the system default @@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const baseConfig = { executablePath: "wsl.exe", - entryPath: wslEntryPath, + entryPath: + preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath, cwd: environment.backendCwd, env: { ...parentEnvWithoutT3Home, @@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...devUrlArgs, ], preflightFailure: Option.none(), + ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }), } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3efc81ed5b..e6c9a2c17c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), @@ -132,6 +133,7 @@ interface MakeInstanceInput { readonly desktopTelemetryPublisher?: Partial< DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] >; + readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; } // Helper that constructs a primary backend instance using the factory @@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) { removeControlSource: () => Effect.void, ...input.desktopTelemetryPublisher, }), + DesktopWslEnvironment.layerTest( + input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes }, + ), ); const instance = DesktopBackendManager.makeBackendInstance({ @@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => { Effect.scoped( Effect.gen(function* () { const requestUrls: Array = []; + const prunedRuntimes: Array<[string | null, string]> = []; const statuses = [503, 200]; let readyCount = 0; const firstRequest = yield* Deferred.make(); - const ready = yield* Deferred.make(); + const backendReady = yield* Deferred.make(); + const processExit = yield* Deferred.make(); + const pruneComplete = yield* Deferred.make(); const exited = yield* Queue.unbounded(); const spawnerLayer = Layer.succeed( @@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => { ChildProcessSpawner.make(() => Effect.succeed( makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + exitCode: Deferred.await(processExit).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), }), ), ), @@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => { const instance = yield* makeTestInstance({ spawnerLayer, + config: { + ...baseConfig, + runningDistro: "Ubuntu", + wslRuntimeId: "1.2.3-x64", + }, + pruneRuntimes: (distro, runtimeId) => + Effect.sync(() => { + prunedRuntimes.push([distro, runtimeId]); + }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid), httpClientLayer: httpClientLayer((request) => Effect.gen(function* () { const status = statuses.shift(); @@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => { ), onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid), backendOutputLog: { persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), }, @@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => { yield* Deferred.await(firstRequest); assert.equal(readyCount, 0); + assert.deepEqual(prunedRuntimes, []); assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(backendReady); + yield* Deferred.await(pruneComplete); + yield* Deferred.succeed(processExit, void 0); yield* Queue.take(exited); assert.equal(readyCount, 1); + assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]); assert.deepEqual(requestUrls, [ "http://127.0.0.1:3773/.well-known/t3/environment", "http://127.0.0.1:3773/.well-known/t3/environment", diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 7a1e4bf502..0ec9bc8a2f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; + // Present only when this run launched from a staged WSL-local runtime. + // Once HTTP readiness succeeds, the manager uses it to retain this cache + // plus the newest previous cache and prune older versions. + readonly wslRuntimeId?: string; } // A preflight failure records whether it is fatal. Transient failures (WSL @@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | HttpClient.HttpClient | DesktopObservability.DesktopBackendOutputLogFactory | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopWslEnvironment.DesktopWslEnvironment | Scope.Scope > { const parentScope = yield* Scope.Scope; @@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + if ( + config.value.runningDistro !== undefined && + config.value.wslRuntimeId !== undefined + ) { + yield* wslEnvironment.pruneRuntimes( + config.value.runningDistro, + config.value.wslRuntimeId, + ); + } }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 5c0fa02c5c..84e09737b0 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; @@ -79,6 +80,7 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), + DesktopWslEnvironment.layerTest(), ElectronDialog.layer, Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 9b85d1bb24..27e24d55c6 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -99,6 +99,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const { logWarning: logBackendPoolWarning } = @@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements = | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | DesktopObservability.DesktopBackendOutputLogFactory - | DesktopTelemetryPublisher.DesktopTelemetryPublisher; + | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopWslEnvironment.DesktopWslEnvironment; interface ActiveRegisteredInstance { readonly _tag: "Active"; diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 92336cc736..e7770dc629 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { PreviewAutomationStatus } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -51,4 +52,46 @@ describe("preview IPC methods", () => { }, ), ); + + effectIt.effect("returns automation status for long runtime tab ids", () => + Effect.gen(function* () { + const tabId = + `["environment-1","thread:delegated-task:${"a".repeat(120)}",` + + `"server-epoch-1","preview-1"]`; + const status = { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }; + const manager = PreviewManager.PreviewManager.of({ + automationStatus: () => Effect.succeed(status), + } as unknown as PreviewManager.PreviewManager["Service"]); + + expect(tabId.length).toBeGreaterThan(128); + expect( + yield* PreviewIpc.automationStatus + .handler({ tabId }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)), + ).toEqual(status); + }), + ); + + it("keeps the public automation status tab id limit", () => { + const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); + const tabId = "t".repeat(129); + + expect(() => + encode({ + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }), + ).toThrow(); + }); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 9850230a03..5229d36c31 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,6 +5,7 @@ import { DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationStatusSchema, DesktopPreviewAutomationTypeInputSchema, DesktopPreviewAutomationWaitForInputSchema, DesktopPreviewConfigInputSchema, @@ -20,7 +21,6 @@ import { DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, - PreviewAutomationStatus, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -282,7 +282,7 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ export const automationStatus = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: PreviewAutomationStatus, + result: DesktopPreviewAutomationStatusSchema, handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; return yield* manager.automationStatus(tabId); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f58bc35929..1fc84c958d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. +const clientPlatform = process.platform; + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getClientPlatform: () => clientPlatform, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 3bf6d63051..9c06d24483 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -58,6 +58,50 @@ describe("isPreviewRefreshShortcut", () => { }); }); +describe("previewWindowOpenAction", () => { + const details = (overrides: { + readonly url?: string; + readonly disposition?: Electron.HandlerDetails["disposition"]; + }) => ({ + url: "https://accounts.google.com/o/oauth2/auth", + disposition: "new-window" as Electron.HandlerDetails["disposition"], + ...overrides, + }); + + it("opens a real window for scripted popups so the opener survives", () => { + // OAuth SDKs read a null `window.open()` as a blocked popup, and they need + // the opener alive to receive the credential back. + expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); + expect( + PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), + ).toBe("popup"); + }); + + it("keeps target=_blank links in the preview tab", () => { + expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( + "navigate", + ); + expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( + "navigate", + ); + }); + + it("does not hand a window to schemes that cannot be hardened", () => { + // A popup skips the `will-attach-webview` hardening, so it only gets a window + // when its preferences can be overridden. Chromium copies the guest's + // preferences for `about:blank` and forbids overriding them. + for (const url of [ + "about:blank", + "javascript:alert(1)", + "file:///etc/passwd", + "vscode://vscode-remote/ssh-remote+box/tmp", + "not a url", + ]) { + expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); + } + }); +}); + const { browserWindowConstructor, createFromPath, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0d90e0175f..0112204503 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ */ import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, @@ -25,7 +26,6 @@ import type { PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, - PreviewAutomationStatus, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; @@ -434,6 +434,61 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +/** + * Protocols a preview page may open in a real popup window. + * + * `about:blank` stays out: Chromium skips browser-side navigation for it, so the + * child copies the guest's `contextIsolation: false` preferences and Electron + * gives no way to override them. Those popups keep loading in the preview tab. + * + * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits + * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` + * and not in a window spawned by a third-party page in the preview. + */ +const POPUP_PROTOCOLS = new Set(["http:", "https:"]); + +const isPopupUrl = (rawUrl: string): boolean => { + try { + return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); + } catch { + return false; + } +}; + +/** + * Preferences for a popup a preview page opens. + * + * A popup is not a webview attach, so the `will-attach-webview` hardening in + * `DesktopWindow` never sees it, and an unoverridden child would inherit the + * guest's relaxed posture: the picker preload needs `contextIsolation: false` + * to share `globalThis` with the previewed page, and no OAuth provider should + * get that. The window keeps the opener and the guest session either way. + */ +const POPUP_WINDOW_OPTIONS = { + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, +} satisfies Electron.BrowserWindowConstructorOptions; + +/** + * Decides what a preview page's `window.open` should do. + * + * `"popup"` opens a real window, which scripted popups need: denying them makes + * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and + * navigating the preview tab instead destroys the opener the popup has to + * `postMessage` its result back to. + * + * `target="_blank"` links arrive as a tab disposition and keep loading in the + * preview tab, which is what people expect from a link inside a preview. + */ +export const previewWindowOpenAction = (details: { + readonly url: string; + readonly disposition: Electron.HandlerDetails["disposition"]; +}): "popup" | "navigate" => + details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; + export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && @@ -1661,6 +1716,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ], }); }); + // A popup opens with Electron's default handler, so the page inside it could + // otherwise spawn native windows without limit. Nothing in an OAuth flow + // opens a second popup, so the chain stops at the first one. + const windowCreated = (window: Electron.BrowserWindow): void => { + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { if (isPreviewRefreshShortcut(input)) { event.preventDefault(); @@ -1686,6 +1747,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); @@ -1704,14 +1766,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); - wc.setWindowOpenHandler(({ url }) => { + wc.setWindowOpenHandler((details) => { + if (previewWindowOpenAction(details) === "popup") { + return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; + } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => - wc.loadURL(url), + wc.loadURL(details.url), ).pipe(Effect.ignore), ); return { action: "deny" }; }); + wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); }); yield* Ref.update(attachedRef, (attached) => @@ -4089,7 +4155,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly automationStatus: ( tabId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly automationSnapshot: ( tabId: string, ) => Effect.Effect; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 13383b2465..28addfb25c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -22,6 +22,7 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, contextMenuStyle: "default", + confirmThreadUnpin: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e36..1c6b64464d 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,5 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. import { describe, it } from "@effect/vitest"; -import { expect } from "vite-plus/test"; +import { afterAll, expect } from "vite-plus/test"; +import * as NodeChildProcess from "node:child_process"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { buildWslNodeEnvPreamble, + buildWslRuntimeInstallScript, + buildWslRuntimeInvalidateScript, + buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, formatNodePtyProbeFailureReason, @@ -19,11 +24,62 @@ import { parseNodeVersion, parseResolvedPath, parseToolchainReport, + parseWslRuntimeRoot, probeWslDistros, + sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); +// The install script only fails the way this file cares about when a real shell +// runs it, so find one that has the tools it needs: bash directly on Linux, and +// the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere +// else the executed suite skips and the generated-text assertions stand alone. +const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; + +const posixShellRunner = (() => { + // Candidates rather than a platform switch: wsl.exe simply fails to spawn + // where it does not exist, which is the same answer as a shell missing flock. + const candidates = [ + { file: "bash", args: [] as ReadonlyArray }, + { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray }, + ]; + const probe = [ + "[ -d /proc/1 ] || exit 1", + ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`), + ].join("\n"); + return ( + candidates.find((candidate) => { + const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], { + encoding: "utf8", + }); + return result.status === 0; + }) ?? null + ); +})(); + +const runShell = (script: string) => { + if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); + // The install script arrives on stdin in production too, which is what lets + // its own /proc scan not match itself. + const result = NodeChildProcess.spawnSync( + posixShellRunner.file, + [...posixShellRunner.args, "-s"], + { input: script, encoding: "utf8" }, + ); + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +}; + +const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +const readField = (stdout: string, field: string) => { + const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`)); + if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`); + return line.slice(field.length + 1).trim(); +}; + +const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; + const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => Effect.succeed( @@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => { }); }); +describe("WSL runtime cache", () => { + it("sanitizes cache ids before interpolating them into Linux paths", () => { + expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + }); + + it("installs through a temporary directory and only reuses valid completed caches", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); + expect(script).toContain(' [ -f "$ready_marker" ] &&'); + expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); + expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); + expect(script).toContain(' node_pty_payload_present "$runtime_root"'); + expect(script).not.toContain("node_modules/effect/package.json"); + expect(script).toContain("if runtime_is_ready; then"); + expect(script).toContain("trap 'exit 1' HUP INT TERM"); + expect(script).toContain('exec 9> "$runtime_lock"'); + expect(script).toContain("flock -x 9"); + expect(script).not.toContain("runtime_lock_pid"); + expect(script).not.toContain("sleep 0.1"); + expect(script).not.toContain('rm -rf "$runtime_lock"'); + expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); + expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); + expect(script).toContain( + "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", + ); + expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); + expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); + expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); + expect(script).not.toContain('rm -rf "$runtime_root"'); + + const lockAcquired = script.indexOf("flock -x 9"); + const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); + const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + expect(lockAcquired).toBeGreaterThan(-1); + expect(readinessAfterLock).toBeGreaterThan(lockAcquired); + expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); + }); + + it("verifies the archive digest before extracting, and only on a cache miss", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + const expected = "b".repeat(64); + expect(script).toContain( + "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", + ); + expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); + + // A warm cache exits before the hash, so reuse never pays for it, and the + // mismatch check runs before anything mutates the cache. + const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); + const digestChecked = script.indexOf("archive_sha=$(sha256sum"); + const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + const extracted = script.indexOf("tar -xzf"); + expect(digestChecked).toBeGreaterThan(readyShortCircuit); + expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); + expect(extracted).toBeGreaterThan(digestChecked); + }); + + // Invalidation revokes the ready marker without stopping the backend that + // failed the probe, so the next install can find an unready tree that a live + // process is still running out of. Deleting it there unlinks node_modules + // under that process; the pruner already refuses to touch in-use caches, and + // the install path has to refuse too. + it("moves an in-use runtime aside instead of deleting it under a live backend", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "sha256-" + "c".repeat(64), + "b".repeat(64), + ); + + expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); + // No /proc means no way to tell, and guessing wrong costs a backend its + // runtime, so an unknowable answer has to count as in use. + expect(script).toContain(" [ -d /proc/1 ] || return 0"); + expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); + + // A process's cmdline keeps the pre-rename path, so the question is only + // answerable before the move. + const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); + const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + expect(inUseChecked).toBeGreaterThan(-1); + expect(inUseChecked).toBeLessThan(moved); + + // In use: keep the tree and restart the sweep's clock, because renaming + // preserves the directory's mtime and a long-installed tree would otherwise + // already be past the age gate. Idle: delete it now, as before. + const kept = script.indexOf('touch "$runtime_stale"'); + const deleted = script.indexOf('rm -rf "$runtime_stale"'); + expect(kept).toBeGreaterThan(moved); + expect(deleted).toBeGreaterThan(kept); + }); + + it("treats a runtime whose native payload went missing as a cache miss", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + // A glob, not a mapped `uname -m`: this is a presence check, and the later + // native probe is what judges arch and loadability. + expect(script).toContain( + ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', + ); + // The marker the probe reads must sit beside the binary, or the runtime is + // just as unusable as one missing pty.node outright. + expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); + + // Readiness gates the short-circuit, so a cache missing the payload + // reinstalls from the archive instead of being reused forever. + const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); + const readinessDefined = script.indexOf("runtime_is_ready() {"); + const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); + expect(payloadCheckDefined).toBeGreaterThan(-1); + expect(payloadCheckDefined).toBeLessThan(readinessDefined); + expect(readinessDefined).toBeLessThan(readyShortCircuit); + }); + + // A truncated or half-written bin.mjs passes every presence check the cache + // had: the file exists, node-pty still loads, and launch then picks a server + // that exits before it becomes ready — forever, because nothing ever + // reinstalls. The digest the install records is what turns that into a miss. + it("re-hashes the server entry against the digest the install recorded", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain( + ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + ); + expect(script).toContain( + ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', + ); + // A runtime installed before the marker carried a digest reads as empty, + // which has to be a miss rather than a pass. + expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); + expect(script).toContain( + `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, + ); + + // The digest is recorded after extraction and before promotion. + const extracted = script.indexOf("tar -xzf"); + const digestRecorded = script.indexOf( + 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', + ); + const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); + const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); + expect(digestRecorded).toBeGreaterThan(extracted); + expect(markerWritten).toBeGreaterThan(digestRecorded); + expect(promoted).toBeGreaterThan(markerWritten); + }); + + it("refuses to mark an archive without a native payload as ready", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); + + // The extracted tree is rejected before the ready marker is written, so a + // defective archive falls back to the mounted tree instead of caching. + const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); + const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); + const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); + expect(payloadValidated).toBeGreaterThan(-1); + expect(markerWritten).toBeGreaterThan(payloadValidated); + expect(promoted).toBeGreaterThan(payloadValidated); + }); + + it("parses only absolute Linux runtime paths", () => { + expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( + "/home/josh/.t3/wsl-runtime/1.2.3-x64", + ); + expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); + expect(parseWslRuntimeRoot("noise\n")).toBeNull(); + }); + + it("prunes completed runtimes except the current and newest previous cache", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); + expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); + expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); + expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); + expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); + expect(script).toContain('rm -rf -- "$candidate"'); + }); + + it("never deletes a runtime another backend is running from", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + // The running backend's argv holds `/apps/server/dist/bin.mjs`, so + // the process itself is the lease and exiting releases it. Nothing has to be + // registered up front, which is what makes this cover backends already + // running from an older version that knows nothing about pruning. + expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); + expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); + + // Without visible processes the retention rules cannot tell a live cache + // from an abandoned one, so the sweep is skipped rather than guessed at. + expect(script).toContain("[ -d /proc/1 ] || exit 0"); + + // The guard has to gate the delete, not just exist. + const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); + const removed = script.indexOf('rm -rf -- "$candidate"'); + expect(inUseChecked).toBeGreaterThan(-1); + expect(removed).toBeGreaterThan(inUseChecked); + }); + + it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry + // no ready marker either; without this pass a killed install leaks forever. + expect(script).toContain( + 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', + ); + // Age guard: a scratch directory younger than this belongs to a live install. + expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); + }); + + it("invalidates a cache by dropping its ready marker, not the tree", () => { + const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); + + // Readiness is a presence check, so a tree whose pty.node is present but + // unloadable stays ready forever unless the probe can revoke the marker. + expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); + // Deleting the tree here would pull it out from under any backend still + // running from it; the next install moves an unready root aside instead. + expect(script).not.toContain("rm -rf"); + }); +}); + +// Reading the generated script proves what it says, not what it does. A cache +// whose bin.mjs was truncated satisfied every assertion above and still got +// reused, so these run the real script against a real archive in a throwaway +// HOME and check the outcome. +describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { + const fixtures: Array = []; + + afterAll(() => { + for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`); + fixtures.length = 0; + }); + + const createFixture = () => { + const result = runShell( + [ + "set -eu", + "work=$(mktemp -d)", + 'stage="$work/stage"', + 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', + `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, + `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, + `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, + `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, + `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, + `printf 'work:%s\\n' "$work"`, + `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, + ].join("\n"), + ); + expect(result.status, result.stderr).toBe(0); + + const work = readField(result.stdout, "work"); + fixtures.push(work); + const archivePath = `${work}/wsl-runtime.tar.gz`; + const archiveSha = readField(result.stdout, "archiveSha"); + const runtimeId = `sha256-${archiveSha}`; + // The script reads $HOME, and WSL does not inherit the parent process's + // environment, so the home override rides in the script itself. + const installScript = (archive = archivePath, sha = archiveSha) => + [ + `HOME=${sh(`${work}/home`)}`, + "export HOME", + buildWslRuntimeInstallScript(archive, runtimeId, sha), + ].join("\n"); + return { + work, + archivePath, + archiveSha, + runtimeId, + runtimeParent: `${work}/home/.t3/wsl-runtime`, + runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, + serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, + installScript, + install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), + }; + }; + + it("reuses a warm cache without touching the archive", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + // Deleting the archive is how the test tells reuse apart from a silent + // reinstall: only the warm path can succeed without it. + expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); + + const warm = fixture.install(); + + expect(warm.status, warm.stderr).toBe(0); + expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); + }); + + it("reinstalls a cache whose server entry was truncated", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); + + const repaired = fixture.install(); + + expect(repaired.status, repaired.stderr).toBe(0); + expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); + const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`); + expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); + }); + + it("falls back instead of launching a corrupted cache it cannot reinstall", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); + expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); + + const broken = fixture.install(); + + // Non-zero with no runtimeRoot is what sends the backend to the mounted + // server tree. Exiting 0 here is the bug: launch would pick the zero-byte + // server, fail to become ready, and do it again on every restart. + expect(broken.status).not.toBe(0); + expect(parseWslRuntimeRoot(broken.stdout)).toBeNull(); + }); + + it("extracts once when two installs race for the same cache", () => { + const fixture = createFixture(); + // A tar shim counts extractions and holds the critical section open long + // enough that the second install is certain to arrive while the first is + // still inside it. One extraction is the answer either way the runs + // interleave: whoever waits for the lock re-checks readiness before + // spending an extract, so a broken lock shows up as two. + const raced = runShell( + [ + "set -eu", + `work=${sh(fixture.work)}`, + 'mkdir -p "$work/bin"', + "real_tar=$(command -v tar)", + `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`, + 'chmod +x "$work/bin/tar"', + ': > "$work/tar-calls"', + 'PATH="$work/bin:$PATH"', + "export PATH", + `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, + fixture.installScript(), + "T3CODE_INSTALL_SCRIPT", + // Both racers run the same file, and neither file path contains the + // runtime root, so the script's own /proc scan cannot see them. + 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', + "first=$!", + 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', + "second=$!", + "if wait $first; then first_status=0; else first_status=$?; fi", + "if wait $second; then second_status=0; else second_status=$?; fi", + `printf 'firstStatus:%s\\n' "$first_status"`, + `printf 'secondStatus:%s\\n' "$second_status"`, + `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`, + `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`, + `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`, + ].join("\n"), + ); + + expect(raced.status, raced.stderr).toBe(0); + expect(readField(raced.stdout, "firstStatus")).toBe("0"); + expect(readField(raced.stdout, "secondStatus")).toBe("0"); + expect(readField(raced.stdout, "extractions")).toBe("1"); + expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot); + expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot); + }); + + it("leaves no half-built cache when extraction fails", () => { + const fixture = createFixture(); + // Truncating the archive and re-recording its digest gets the install past + // the digest gate and into a tar that dies mid-stream, which is what a full + // disk or an interrupted write looks like from inside the distro. + const truncated = runShell( + [ + "set -eu", + `work=${sh(fixture.work)}`, + 'size=$(wc -c < "$work/wsl-runtime.tar.gz")', + 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"', + `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`, + ].join("\n"), + ); + expect(truncated.status, truncated.stderr).toBe(0); + + const failed = fixture.install( + `${fixture.work}/truncated.tar.gz`, + readField(truncated.stdout, "sha"), + ); + + expect(failed.status).not.toBe(0); + expect(parseWslRuntimeRoot(failed.stdout)).toBeNull(); + // A partial extract that survived under the cache name would be promoted by + // the next launch's readiness check; scratch that survived would sit there + // until the pruner's age sweep. Neither is left behind. Only directories + // are counted: the empty flock file stays on purpose, which is what keeps + // the lock from carrying stale state across a killed install. + const leftovers = runShell( + `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, + ); + expect(leftovers.stdout.trim()).toBe(""); + }); + + // The archive and the identity recorded beside it can diverge — a partial + // download, or a rebuilt archive dropped next to an older sidecar. Either + // gate firing means the bytes never reach the cache under a name that claims + // to describe something else. + it("refuses an archive whose bytes do not match the digest recorded for it", () => { + const fixture = createFixture(); + + const refused = fixture.install(fixture.archivePath, "e".repeat(64)); + + expect(refused.status).not.toBe(0); + expect(refused.stderr).toContain("does not match its recorded SHA-256"); + expect(parseWslRuntimeRoot(refused.stdout)).toBeNull(); + const leftovers = runShell( + `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, + ); + expect(leftovers.stdout.trim()).toBe(""); + }); + + it("leases a warm cache across the prepare-to-spawn handoff", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const selected = runShell( + [ + "set -eu", + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', + 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', + 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', + `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`, + fixture.installScript(), + "T3CODE_SELECT_SCRIPT", + `sh ${sh(`${fixture.work}/select.sh`)}`, + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + `test -d ${sh(fixture.runtimeRoot)}`, + ].join("\n"), + ); + + expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); + }); + + it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const result = runShell( + [ + "set -eu", + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', + 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', + 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', + `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, + `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + `test ! -e ${sh(fixture.runtimeRoot)}`, + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); + + it("removes an aged stale tree after replacing an active unready cache", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const result = runShell( + [ + "set -eu", + `runtime_root=${sh(fixture.runtimeRoot)}`, + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'rm "$runtime_root/.t3code-wsl-runtime-ready"', + 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + "active_pid=$!", + "sleep 0.1", + fixture.installScript(), + 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', + 'test -n "$stale"', + 'touch -d "180 minutes ago" "$stale"', + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript(fixture.runtimeId), + 'test ! -e "$stale"', + "kill $active_pid", + "wait $active_pid 2>/dev/null || true", + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); + + it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { + const result = runShell( + [ + "set -eu", + "work=$(mktemp -d)", + 'home="$work/home"', + 'runtime_parent="$home/.t3/wsl-runtime"', + 'mkdir -p "$runtime_parent"', + 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', + "make_ready sha256-current", + "make_ready sha256-previous", + "make_ready sha256-active", + "make_ready sha256-old", + "make_ready sha256-locked", + 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"', + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', + 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', + 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', + 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + "active_pid=$!", + "(", + ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', + " flock -x 9", + " sleep 30", + ") >/dev/null 2>&1 &", + "lock_pid=$!", + "sleep 0.1", + `HOME="$home"`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + 'test -d "$runtime_parent/sha256-current"', + 'test -d "$runtime_parent/sha256-previous"', + 'test -d "$runtime_parent/sha256-active"', + 'test -d "$runtime_parent/sha256-locked"', + 'test -d "$runtime_parent/versions"', + 'test ! -e "$runtime_parent/sha256-old"', + 'test ! -e "$runtime_parent/sha256-markerless"', + "kill $active_pid $lock_pid", + "wait $active_pid 2>/dev/null || true", + "wait $lock_pid 2>/dev/null || true", + 'rm -rf "$work"', + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); +}); + describe("parseToolchainReport", () => { it("returns no missing tools and no node version on empty output", () => { expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index dfd0c8eb47..56ccd5a8a2 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); +const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); +const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); +const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); const USER_HOME_TIMEOUT = Duration.seconds(5); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; @@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions { readonly nodeEngineRange?: string | null; } +// The packaged WSL runtime archive plus the SHA-256 identity the build recorded +// for it. The cache key derives from the same digest, and installation verifies +// the bytes before promoting the extracted tree. +export interface WslRuntimeArchive { + readonly windowsPath: string; + readonly runtimeId: string; + readonly sha256: string; +} + +export type PrepareWslRuntimeResult = + | { + readonly ok: true; + readonly linuxAppRoot: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export type EnsureWslNodePtyResult = | { readonly ok: true; @@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service< // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect>; + readonly prepareRuntime: ( + distro: string | null, + archive: WslRuntimeArchive, + ) => Effect.Effect; + readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; + // Marks a staged runtime as unusable so the next launch reinstalls it. + readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, - windowsRepoRoot: string, + linuxAppRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect; } @@ -149,18 +178,28 @@ const runWslShell = ( distro: string | null, bashScript: string, timeout: Duration.Duration, - options: EnsureWslNodePtyOptions = {}, + options: { + readonly nodeEngineRange?: string | null; + readonly resolveNode?: boolean; + } = {}, ): Effect.Effect => { const spawner = ChildProcessSpawner.ChildProcessSpawner; - // -l picks up profile-managed PATH; the shared resolver covers supported - // version managers that non-interactive login shells can miss. -s so bash - // reads the script from stdin. + // Node probes use a login bash so profile-managed PATH entries and supported + // version managers are available. Runtime installation needs only POSIX tools, + // so it skips profile loading and runs sh directly. + const resolveNode = options.resolveNode !== false; const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "bash", "-l", "-s"], + resolveNode + ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] + : [...buildDistroArgs(distro), "--exec", "sh", "-s"], { stdin: Stream.encodeText( - Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`), + Stream.make( + resolveNode + ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` + : bashScript, + ), ), stdout: "pipe", stderr: "pipe", @@ -216,6 +255,240 @@ const runWslShell = ( const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +// Holds the sha256 of the runtime's server entry, written when the install +// promotes a verified tree. Presence alone only says an install once finished +// here; the digest is what lets a later launch prove the entry still is what +// that install wrote. +const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; +const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; +const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; + +export const sanitizeWslRuntimeId = (value: string): string => + value.replace(/[^A-Za-z0-9._-]/g, "_"); + +// `archiveSha256` is the digest the build recorded alongside the archive. The +// install verifies the bytes before extracting, so an archive can never be +// promoted under an identity that does not describe it. +export const buildWslRuntimeInstallScript = ( + linuxArchivePath: string, + runtimeId: string, + archiveSha256: string, +): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + 'runtime_parent="$HOME/.t3/wsl-runtime"', + `runtime_root="$runtime_parent/${safeRuntimeId}"`, + `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, + // The native payload is the part of the tree the WSL backend actually + // dlopens, and the only part a user can plausibly break by hand. Checking + // node-pty's package.json alone let a runtime whose pty.node had gone + // missing stay cache-ready forever: every launch reused it and then failed + // the native probe, with no reinstall and no fallback. Match on the glob + // rather than a mapped `uname -m` so this stays a presence check; the probe + // is what decides whether the binary is the right arch and loadable. + "node_pty_payload_present() {", + ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', + ' [ -f "$candidate" ] || continue', + ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', + " return 0", + " done", + " return 1", + "}", + // Hashing the server entry is the only check that can tell a working cache + // from one whose bin.mjs was truncated or half-written: the file is still + // there, the native probe still passes, and launch then picks a server that + // exits before it can become ready, on every restart. Hashing the ~7MB + // entry measures in single-digit milliseconds inside the distro, once per + // launch, against a cold reinstall of a few hundred megabytes. + "runtime_server_entry_digest() {", + ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + "}", + "runtime_is_ready() {", + ' [ -f "$ready_marker" ] &&', + ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', + ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', + ' node_pty_payload_present "$runtime_root" &&', + // An empty or unreadable marker is a miss, not a pass: that is what a + // runtime installed before the marker carried a digest looks like, and one + // reinstall is the cheapest way to make it verifiable from then on. + ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`, + ' [ -n "$recorded_entry_digest" ] &&', + ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', + "}", + 'mkdir -p "$runtime_parent"', + `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`, + "trap 'exit 1' HUP INT TERM", + 'exec 9> "$runtime_lock"', + "flock -x 9", + "if runtime_is_ready; then", + ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, + ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, + " exit 0", + "fi", + // Hash only on a cache miss: a warm launch already exited above, and a cold + // install is about to read the whole archive through tar anyway. `set -eu` + // turns a distro without sha256sum into an install failure, which falls back + // to the mounted server tree rather than trusting unverified bytes. + `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`, + `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`, + ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`, + " exit 1", + "fi", + // A backend can still be running out of an unready tree: the probe revokes + // the ready marker without stopping the process it just failed for, and + // invalidation deliberately leaves the tree in place for exactly that + // reason. Deleting it here unlinks node_modules from under a live backend, + // which then breaks the moment it lazily loads anything it had not already + // read. Move it aside either way, but only delete it now when nothing is + // running from it; otherwise hand it to the pruner's scratch sweep, which + // is what that delay is for. A process's cmdline keeps the pre-rename path, + // so this has to be asked before the move, not after. This script arrives + // on stdin, so it cannot match itself. + "runtime_in_use() {", + // No /proc means no way to tell, and guessing wrong costs a live backend + // its runtime. Keeping the tree only costs disk until the sweep runs. + " [ -d /proc/1 ] || return 0", + ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', + "}", + 'if [ -e "$runtime_root" ]; then', + ' if runtime_in_use "$runtime_root"; then', + " runtime_root_in_use=1", + " else", + " runtime_root_in_use=0", + " fi", + ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`, + ' rmdir "$runtime_stale"', + ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then', + ' if [ "$runtime_root_in_use" = 1 ]; then', + // Renaming keeps the directory's old mtime, so restart the cleanup clock. + ' touch "$runtime_stale"', + " else", + ' rm -rf "$runtime_stale"', + " fi", + " fi", + "fi", + `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, + 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', + "trap cleanup_runtime_install EXIT", + `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, + 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', + 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', + + // Never write the ready marker over a tree that is missing the native + // payload. Failing here drops out to the mounted-tree fallback, which is + // recoverable; promoting it would mark the defect ready and cache it. + 'if ! node_pty_payload_present "$runtime_tmp"; then', + " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", + " exit 1", + "fi", + // The archive's bytes were verified against archiveSha256 above, so the + // digest recorded here describes content this install proved. Every later + // warm reuse checks the entry against it. + 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', + 'if [ -z "$installed_entry_digest" ]; then', + " printf 'Could not hash the WSL runtime server entry\\n' >&2", + " exit 1", + "fi", + `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`, + 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then', + " :", + "elif runtime_is_ready; then", + ' rm -rf "$runtime_tmp"', + "else", + ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`, + " exit 1", + "fi", + `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, + `printf 'runtimeRoot:%s\\n' "$runtime_root"`, + ].join("\n"); +}; + +// An interrupted install leaves a dot-prefixed scratch directory behind. A cold +// install extracts a few hundred MB inside the distro, so two hours is far past +// any live install while still bounding how long an orphan survives. +const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120; + +export const buildWslRuntimePruneScript = (runtimeId: string): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + 'runtime_parent="$HOME/.t3/wsl-runtime"', + `current_runtime="$runtime_parent/${safeRuntimeId}"`, + '[ -d "$runtime_parent" ] || exit 0', + // Serialize the whole retention decision so two backends cannot select + // different "previous" caches and delete around one another. + 'prune_lock="$runtime_parent/.prune.lock"', + 'exec 8> "$prune_lock"', + "flock -x 8", + // Without a way to see the distro's processes we cannot tell which caches + // are load-bearing, and the retention rules below are not safe on their own. + "[ -d /proc/1 ] || exit 0", + "runtime_in_use() {", + ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', + "}", + 'previous_runtime=""', + 'for candidate in "$runtime_parent"/sha256-*; do', + ' [ -d "$candidate" ] || continue', + ' [ "$candidate" != "$current_runtime" ] || continue', + ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`, + ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then', + ' previous_runtime="$candidate"', + " fi", + "done", + // Only this desktop-owned prefix is eligible. Markerless roots are broken + // caches left by invalidation and must not become permanent disk leaks. + 'for candidate in "$runtime_parent"/sha256-*; do', + ' [ -d "$candidate" ] || continue', + ' [ "$candidate" != "$current_runtime" ] || continue', + ' [ "$candidate" != "$previous_runtime" ] || continue', + ' ! runtime_in_use "$candidate" || continue', + " candidate_name=${candidate##*/}", + ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"', + ' exec 9> "$candidate_lock"', + // A held lock means another launch is installing or repairing this cache. + // Skip instead of waiting or deleting underneath it. + " flock -n 9 || continue", + ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`, + ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`, + " flock -u 9", + " continue", + " fi", + ' rm -rf -- "$candidate"', + " flock -u 9", + "done", + // Interrupted installs use dot-prefixed names under this dedicated parent. + 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', + ' [ -d "$scratch" ] || continue', + ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`, + ' rm -rf -- "$scratch"', + "done", + ].join("\n"); +}; + +// Drops the ready marker so the next launch reinstalls the runtime from the +// archive. Readiness is a presence check by design, so a cached tree whose +// native payload is present but unloadable (truncated pty.node, a distro whose +// glibc the binary needs and the tree was copied from another machine) stays +// ready forever and fails the probe on every launch. Only the probe can see +// that, so the probe is what revokes the marker. The tree itself is left in +// place: the install script moves an unready root aside before extracting. +export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, + ].join("\n"); +}; + +export const parseWslRuntimeRoot = (stdout: string): string | null => { + const prefix = "runtimeRoot:"; + const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); + if (line === undefined) return null; + const runtimeRoot = line.slice(prefix.length).replace(/\r$/, ""); + return runtimeRoot.startsWith("/") ? runtimeRoot : null; +}; + const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => @@ -390,23 +663,10 @@ export const formatMissingToolsReason = ( const ensureNodePtyImpl = ( distro: string | null, - windowsRepoRoot: string, - windowsToWslPath: ( - distro: string | null, - windowsPath: string, - ) => Effect.Effect>, + linuxRepoRoot: string, options: EnsureWslNodePtyOptions = {}, ): Effect.Effect => Effect.gen(function* () { - const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot); - if (Option.isNone(linuxRepoRootOption)) { - return { - ok: false, - reason: `wslpath conversion failed for ${windowsRepoRoot}`, - fatal: false, - } as const; - } - const linuxRepoRoot = linuxRepoRootOption.value; // node-pty lives in the apps/server workspace's node_modules; resolve from // there rather than the monorepo root, where Bun's hoist layout omits it. const linuxServerDir = `${linuxRepoRoot}/apps/server`; @@ -584,6 +844,96 @@ const ensureNodePtyImpl = ( } as const; }); +const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( + distro: string | null, + archive: WslRuntimeArchive, + windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>, +): Effect.fn.Return { + const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); + if (Option.isNone(linuxArchivePath)) { + return { + ok: false, + reason: `wslpath conversion failed for ${archive.windowsPath}`, + } as const; + } + + const install = yield* runWslShell( + distro, + buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256), + RUNTIME_INSTALL_TIMEOUT, + { resolveNode: false }, + ); + if (install.transportFailure !== null) { + return { + ok: false, + reason: + install.transportFailure === "timeout" + ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry." + : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.", + } as const; + } + if (install.exitCode !== 0) { + const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500); + return { + ok: false, + reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`, + } as const; + } + + const linuxAppRoot = parseWslRuntimeRoot(install.stdout); + return linuxAppRoot === null + ? { + ok: false, + reason: "WSL runtime installation completed without reporting its cache path.", + } + : { ok: true, linuxAppRoot }; +}); + +const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* ( + distro: string | null, + runtimeId: string, +): Effect.fn.Return { + const result = yield* runWslShell( + distro, + buildWslRuntimePruneScript(runtimeId), + RUNTIME_PRUNE_TIMEOUT, + { resolveNode: false }, + ); + if (result.transportFailure === null && result.exitCode === 0) return; + + const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); + yield* Effect.logWarning("Could not prune old WSL runtime caches.", { + distro, + runtimeId, + detail: detail || `exit ${result.exitCode}`, + }); +}); + +const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* ( + distro: string | null, + runtimeId: string, +): Effect.fn.Return { + const result = yield* runWslShell( + distro, + buildWslRuntimeInvalidateScript(runtimeId), + RUNTIME_INVALIDATE_TIMEOUT, + { resolveNode: false }, + ); + if (result.transportFailure === null && result.exitCode === 0) return; + + const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); + // Best effort: the caller has already fallen back to the mounted tree, so a + // failure here only costs the reinstall that would have repaired the cache. + yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", { + distro, + runtimeId, + detail: detail || `exit ${result.exitCode}`, + }); +}); + export const probeWslDistros: Effect.Effect< readonly WslDistro[], DesktopWslDistroListError, @@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub { readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option; readonly getUserHome?: (distro: string | null) => Option.Option; readonly getDistroIp?: (distro: string | null) => Option.Option; + readonly prepareRuntime?: ( + distro: string | null, + archive: WslRuntimeArchive, + ) => PrepareWslRuntimeResult; + readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; + readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect; readonly ensureNodePty?: ( distro: string | null, - windowsRepoRoot: string, + linuxAppRoot: string, options?: EnsureWslNodePtyOptions, ) => EnsureWslNodePtyResult; } @@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), - ensureNodePty: (distro, windowsRepoRoot, options) => + prepareRuntime: (distro, archive) => + Effect.succeed( + stub.prepareRuntime?.(distro, archive) ?? { + ok: false, + reason: "prepareRuntime stub not configured", + }, + ), + pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, + invalidateRuntime: (distro, runtimeId) => + stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, + ensureNodePty: (distro, linuxAppRoot, options) => Effect.succeed( - stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? { + stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { ok: false, reason: "ensureNodePty stub not configured", fatal: true, @@ -881,8 +1247,20 @@ export const layer = Layer.effect( windowsToWslPath, getUserHome, getDistroIp, - ensureNodePty: (distro, windowsRepoRoot, options) => - provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe( + prepareRuntime: (distro, archive) => + provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( + Effect.withSpan("desktop.wsl.prepareRuntime"), + ), + pruneRuntimes: (distro, runtimeId) => + provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( + Effect.withSpan("desktop.wsl.pruneRuntimes"), + ), + invalidateRuntime: (distro, runtimeId) => + provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( + Effect.withSpan("desktop.wsl.invalidateRuntime"), + ), + ensureNodePty: (distro, linuxAppRoot, options) => + provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), ), }); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 8c1a5b020b..0c02080616 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("removes the legacy Windows extraction tree without preparing a fallback", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old"); + + yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + yield* tree.cleanupLegacy; + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + ), + ), + ); + + assert.isFalse(yield* fileSystem.exists(treeRoot)); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const serverRoot = path.join(resourcesPath, "server.asar"); + const sourceEntryPath = path.join(serverRoot, "apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(sourceEntryPath), { recursive: true }); + yield* fileSystem.writeFileString(sourceEntryPath, "fresh-server-entry"); + + const initial = yield* ensureWith({ baseDir: tempDir, resourcesPath }); + assert.isTrue(initial.ok); + const versionDir = initial.ok ? initial.root : ""; + const treeRoot = path.dirname(versionDir); + const extractedEntryPath = path.join(versionDir, "apps/server/dist/bin.mjs"); + let cleanupFailed = false; + const partialCleanupFileSystem = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const realFileSystem = yield* FileSystem.FileSystem; + return { + ...realFileSystem, + remove: (target, options) => + String(target) === treeRoot && options?.recursive === true && !cleanupFailed + ? Effect.gen(function* () { + cleanupFailed = true; + yield* realFileSystem.remove(extractedEntryPath); + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: treeRoot, + description: "simulated partial legacy cleanup", + }); + }) + : realFileSystem.remove(target, options), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + const result = yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + yield* tree.cleanupLegacy; + return yield* tree.ensure; + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + Layer.provideMerge(partialCleanupFileSystem), + ), + ), + ); + + assert.isTrue(cleanupFailed); + assert.isTrue(result.ok); + assert.equal(yield* fileSystem.readFileString(extractedEntryPath), "fresh-server-entry"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("reports a retryable failure when the archive cannot be read", () => withTempDir((tempDir) => Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts index 0b87f7bf1f..c6094e780c 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; // Packaged Windows builds ship the server tree inside resources/server.asar // (see scripts/build-desktop-artifact.ts). The Windows primary reads it in // place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL -// backend launches plain `wsl.exe -- node`, which cannot read an asar -// archive. This service materializes the archive into a real, version-keyed -// directory the first time the WSL backend starts, and reuses it afterwards — -// so only users who enable WSL ever pay for a loose copy of the server tree. +// backend launches plain `wsl.exe -- node`, which cannot read an asar archive. +// This fallback service materializes the archive into a real, version-keyed +// directory only when the distro-local runtime cannot be prepared. // // Reading through Electron's patched fs also transparently returns the // contents of files that electron-builder/asar left in the server.asar.unpacked @@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service< // the checkout already is that directory; packaged Windows builds extract // server.asar on first use. readonly ensure: Effect.Effect; + // Removes the Windows-side extraction cache after a distro-local runtime + // has proven healthy. Serialized with ensure so cleanup cannot race an + // extraction that the mounted fallback is preparing. + readonly cleanupLegacy: Effect.Effect; } >()("@t3tools/desktop/wsl/DesktopWslServerTree") {} @@ -173,6 +176,28 @@ export const make = Effect.gen(function* () { // first caller extracts, later callers see the marker and reuse the tree. const gate = yield* Semaphore.make(1); + const cleanupLegacy = gate + .withPermits(1)( + needsExtraction + ? Effect.gen(function* () { + // Invalidate completeness before recursive deletion. Windows can + // remove part of a tree and then fail on a locked file; without + // this ordering, a surviving marker makes ensure reuse that + // half-deleted fallback instead of extracting it again. + yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true }); + yield* fs.remove(treeRoot, { recursive: true, force: true }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", { + treeRoot, + cause, + }), + ), + ) + : Effect.void, + ) + .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy")); + const ensure: Effect.Effect = gate .withPermits(1)( Effect.gen(function* () { @@ -205,13 +230,14 @@ export const make = Effect.gen(function* () { ) .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); - return DesktopWslServerTree.of({ ensure }); + return DesktopWslServerTree.of({ ensure, cleanupLegacy }); }); export const layer = Layer.effect(DesktopWslServerTree, make); export interface DesktopWslServerTreeTestStub { readonly result?: WslServerTreeResult; + readonly cleanupLegacy?: Effect.Effect; } export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => @@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopWslServerTree.of({ ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), + cleanupLegacy: stub.cleanupLegacy ?? Effect.void, }); }), ); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a51725478..0dc8dca968 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -15,6 +15,9 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID? const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; +// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive +// foreground makes Android draw an icon shape inside the launcher's mask. +const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; if ( isIosPersonalTeamBuild && @@ -30,7 +33,7 @@ const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -41,7 +44,7 @@ const PREVIEW_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#111533", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -52,7 +55,7 @@ const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), - androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 0000000000..4f4374c7eb Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-foreground.svg b/apps/mobile/assets/android-icon-foreground.svg new file mode 100644 index 0000000000..8d974992ab --- /dev/null +++ b/apps/mobile/assets/android-icon-foreground.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/generated-uniwind-default-theme-variables.json similarity index 93% rename from apps/mobile/src/lib/mobileDefaultTheme.ts rename to apps/mobile/generated-uniwind-default-theme-variables.json index 66afae4647..427d370acb 100644 --- a/apps/mobile/src/lib/mobileDefaultTheme.ts +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -1,8 +1,5 @@ -import type { MobileThemeVariables } from "./mobileTheme"; - -/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */ -export const DEFAULT_MOBILE_THEME_VARIABLES = { - light: { +{ + "light": { "--color-screen": "#f2f2f7", "--color-sheet": "rgba(242, 242, 247, 0.98)", "--color-sheet-solid": "#f2f2f7", @@ -67,9 +64,9 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", "--color-dot-separator": "rgba(0, 0, 0, 0.2)", "--color-wordmark": "#262626", - "--color-chevron": "rgba(0, 0, 0, 0.2)", + "--color-chevron": "rgba(0, 0, 0, 0.2)" }, - dark: { + "dark": { "--color-screen": "#0a0a0a", "--color-sheet": "rgba(14, 14, 14, 0.98)", "--color-sheet-solid": "#0e0e0e", @@ -134,6 +131,6 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", "--color-dot-separator": "rgba(255, 255, 255, 0.2)", "--color-wordmark": "#f5f5f5", - "--color-chevron": "rgba(255, 255, 255, 0.2)", - }, -} as const satisfies Readonly>; + "--color-chevron": "rgba(255, 255, 255, 0.2)" + } +} diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json new file mode 100644 index 0000000000..4ec9f01f8e --- /dev/null +++ b/apps/mobile/generated-uniwind-theme-names.json @@ -0,0 +1,12 @@ +[ + "t3-chat-light", + "t3-chat-dark", + "grove-light", + "grove-dark", + "ocean-light", + "ocean-dark", + "ember-light", + "ember-dark", + "iris-light", + "iris-dark" +] diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css new file mode 100644 index 0000000000..7f8f9c16af --- /dev/null +++ b/apps/mobile/generated-uniwind-themes.css @@ -0,0 +1,1374 @@ +/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */ +@layer theme { + :root { + @variant light { + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant dark { + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant t3-chat-light { + --color-screen: #fdf7fd; + --color-sheet: rgba(253, 247, 253, 0.98); + --color-sheet-solid: #fdf7fd; + --color-card: #fdfafd; + --color-card-alt: #faf3fb; + --color-card-translucent: rgba(253, 250, 253, 0.8); + --color-foreground: #501854; + --color-foreground-secondary: #ac1668; + --color-foreground-muted: #8d1255; + --color-foreground-tertiary: #ac1668; + --color-border: #eee1ed; + --color-border-subtle: rgba(238, 225, 237, 0.7); + --color-separator: rgba(238, 225, 237, 0.55); + --color-subtle: #eaa7cb; + --color-subtle-strong: #f1c4e6; + --color-inline-skill-background: #f3e6f5; + --color-inline-skill-border: rgba(219, 39, 119, 0.42); + --color-inline-skill-foreground: #454554; + --color-primary: #db2777; + --color-primary-foreground: #ffffff; + --color-primary-shadow: #000000; + --color-secondary: #f1c4e6; + --color-secondary-foreground: #77347c; + --color-secondary-border: #eee1ed; + --color-switch-active-track: #db2777; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #f1c4e6; + --color-switch-inactive-thumb: #8d1255; + --color-danger: #fde4f1; + --color-danger-border: rgba(247, 8, 108, 0.32); + --color-danger-foreground: #9d174d; + --color-input: #fdfafd; + --color-input-border: #e7c1dc; + --color-sidebar-search: #f8f8f7; + --color-placeholder: #8b5f90; + --color-icon: #501854; + --color-icon-muted: #ac1668; + --color-icon-subtle: #ac1668; + --color-header: rgba(253, 247, 253, 0.97); + --color-header-border: #efbdeb; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #fdf7fd; + --color-md-body: #501854; + --color-md-strong: #501854; + --color-md-link: #db2777; + --color-md-blockquote-border: #eee1ed; + --color-md-blockquote-bg: #eaa7cb; + --color-md-code-bg: #f5ecf9; + --color-md-code-text: #673c8b; + --color-md-user-code-bg: rgba(73, 44, 97, 0.18); + --color-md-user-code-text: #492c61; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #492c61; + --color-md-hr: #eee1ed; + --color-user-bubble: #f7def2; + --color-user-bubble-foreground: #492c61; + --color-user-bubble-foreground-muted: rgba(73, 44, 97, 0.78); + --color-user-bubble-skill-foreground: #c12269; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(242, 225, 244, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(172, 22, 104, 0.35); + --color-wordmark: #501854; + --color-chevron: rgba(172, 22, 104, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant t3-chat-dark { + --color-screen: #1f1a24; + --color-sheet: rgba(31, 26, 36, 0.98); + --color-sheet-solid: #1f1a24; + --color-card: #2c2631; + --color-card-alt: #29232d; + --color-card-translucent: rgba(44, 38, 49, 0.8); + --color-foreground: #f9f8fb; + --color-foreground-secondary: #e7d0dd; + --color-foreground-muted: #e7d0dd; + --color-foreground-tertiary: #e7d0dd; + --color-border: #27242c; + --color-border-subtle: rgba(39, 36, 44, 0.7); + --color-separator: rgba(39, 36, 44, 0.55); + --color-subtle: #423a45; + --color-subtle-strong: #362d3d; + --color-inline-skill-background: #463753; + --color-inline-skill-border: rgba(163, 0, 76, 0.42); + --color-inline-skill-foreground: #f8f1f5; + --color-primary: #a3004c; + --color-primary-foreground: #fbd0e8; + --color-primary-shadow: #000000; + --color-secondary: #362d3d; + --color-secondary-foreground: #d4c7e1; + --color-secondary-border: #27242c; + --color-switch-active-track: #a3004c; + --color-switch-active-thumb: #fbd0e8; + --color-switch-inactive-track: #362d3d; + --color-switch-inactive-thumb: #e7d0dd; + --color-danger: #331a2b; + --color-danger-border: rgba(157, 23, 77, 0.32); + --color-danger-foreground: #fbd0e8; + --color-input: #2c2631; + --color-input-border: #302029; + --color-sidebar-search: #261922; + --color-placeholder: #968d9f; + --color-icon: #f9f8fb; + --color-icon-muted: #d4c7e1; + --color-icon-subtle: #e7d0dd; + --color-header: rgba(31, 26, 36, 0.97); + --color-header-border: #27242c; + --color-glass-surface: rgba(16, 10, 14, 0.74); + --color-glass-tint: rgba(16, 10, 14, 0.22); + --color-status-bar: #1f1a24; + --color-md-body: #f9f8fb; + --color-md-strong: #f9f8fb; + --color-md-link: #a3004c; + --color-md-blockquote-border: #27242c; + --color-md-blockquote-bg: #423a45; + --color-md-code-bg: #1f1a24; + --color-md-code-text: #d8c3ef; + --color-md-user-code-bg: rgba(242, 235, 250, 0.18); + --color-md-user-code-text: #f2ebfa; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #f2ebfa; + --color-md-hr: #27242c; + --color-user-bubble: #2b2431; + --color-user-bubble-foreground: #f2ebfa; + --color-user-bubble-foreground-muted: rgba(242, 235, 250, 0.78); + --color-user-bubble-skill-foreground: #cb709a; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(23, 16, 24, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(231, 208, 221, 0.35); + --color-wordmark: #f9f8fb; + --color-chevron: rgba(231, 208, 221, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant grove-light { + --color-screen: #f3f7f4; + --color-sheet: rgba(243, 247, 244, 0.98); + --color-sheet-solid: #f3f7f4; + --color-card: #ecefed; + --color-card-alt: #f3f7f4; + --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #746c73; + --color-foreground-muted: #6e696f; + --color-foreground-tertiary: #746c73; + --color-border: #cbd5d1; + --color-border-subtle: rgba(203, 213, 209, 0.7); + --color-separator: rgba(203, 213, 209, 0.55); + --color-subtle: #e6f0ea; + --color-subtle-strong: #e2ede7; + --color-inline-skill-background: #d5e6dd; + --color-inline-skill-border: rgba(27, 125, 80, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #1b7d50; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #e2ede7; + --color-secondary-foreground: #241523; + --color-secondary-border: #cbd5d1; + --color-switch-active-track: #1b7d50; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #e2ede7; + --color-switch-inactive-thumb: #6e696f; + --color-danger: #f4e7e5; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #ecefed; + --color-input-border: #becbc5; + --color-sidebar-search: #d3dcd8; + --color-placeholder: #716971; + --color-icon: #241523; + --color-icon-muted: #746c73; + --color-icon-subtle: #746c73; + --color-header: rgba(243, 247, 244, 0.97); + --color-header-border: #d5e6dd; + --color-glass-surface: rgba(231, 233, 232, 0.74); + --color-glass-tint: rgba(231, 233, 232, 0.22); + --color-status-bar: #f3f7f4; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #1b7d50; + --color-md-blockquote-border: #cbd5d1; + --color-md-blockquote-bg: #e6f0ea; + --color-md-code-bg: #eef1ef; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #cbd5d1; + --color-user-bubble: #cce1d7; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #815a0e; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(226, 237, 231, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(116, 108, 115, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(116, 108, 115, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant grove-dark { + --color-screen: #1b2821; + --color-sheet: rgba(27, 40, 33, 0.98); + --color-sheet-solid: #1b2821; + --color-card: #36413c; + --color-card-alt: #1b2821; + --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #919595; + --color-foreground-muted: #9da5a2; + --color-foreground-tertiary: #919595; + --color-border: #415f4f; + --color-border-subtle: rgba(65, 95, 79, 0.7); + --color-separator: rgba(65, 95, 79, 0.55); + --color-subtle: #253e31; + --color-subtle-strong: #2a4b39; + --color-inline-skill-background: #325c46; + --color-inline-skill-border: rgba(105, 214, 154, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #69d69a; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #2a4b39; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #415f4f; + --color-switch-active-track: #69d69a; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #2a4b39; + --color-switch-inactive-thumb: #9da5a2; + --color-danger: #3f2c28; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6668; + --color-input: #36413c; + --color-input-border: #4f725f; + --color-sidebar-search: #45554d; + --color-placeholder: #a9abab; + --color-icon: #fffaff; + --color-icon-muted: #919595; + --color-icon-subtle: #919595; + --color-header: rgba(27, 40, 33, 0.97); + --color-header-border: #36654c; + --color-glass-surface: rgba(68, 77, 73, 0.74); + --color-glass-tint: rgba(68, 77, 73, 0.22); + --color-status-bar: #1b2821; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #69d69a; + --color-md-blockquote-border: #415f4f; + --color-md-blockquote-bg: #253e31; + --color-md-code-bg: #28342e; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #415f4f; + --color-user-bubble: #37664d; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #eed295; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(33, 54, 43, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(145, 149, 149, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(145, 149, 149, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant ocean-light { + --color-screen: #f5f7f8; + --color-sheet: rgba(245, 247, 248, 0.98); + --color-sheet-solid: #f5f7f8; + --color-card: #edeff1; + --color-card-alt: #f5f7f8; + --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #746c75; + --color-foreground-muted: #6f6873; + --color-foreground-tertiary: #746c75; + --color-border: #cdd4dc; + --color-border-subtle: rgba(205, 212, 220, 0.7); + --color-separator: rgba(205, 212, 220, 0.55); + --color-subtle: #e8eff4; + --color-subtle-strong: #e4ecf2; + --color-inline-skill-background: #d8e4ee; + --color-inline-skill-border: rgba(38, 114, 175, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #2672af; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #e4ecf2; + --color-secondary-foreground: #241523; + --color-secondary-border: #cdd4dc; + --color-switch-active-track: #2672af; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #e4ecf2; + --color-switch-inactive-thumb: #6f6873; + --color-danger: #f5e6e9; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #edeff1; + --color-input-border: #c0c9d4; + --color-sidebar-search: #d5dbe2; + --color-placeholder: #716972; + --color-icon: #241523; + --color-icon-muted: #746c75; + --color-icon-subtle: #746c75; + --color-header: rgba(245, 247, 248, 0.97); + --color-header-border: #d8e4ee; + --color-glass-surface: rgba(232, 233, 235, 0.74); + --color-glass-tint: rgba(232, 233, 235, 0.22); + --color-status-bar: #f5f7f8; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #2672af; + --color-md-blockquote-border: #cdd4dc; + --color-md-blockquote-bg: #e8eff4; + --color-md-code-bg: #f0f1f3; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #cdd4dc; + --color-user-bubble: #d0dfeb; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #0a6c72; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(228, 236, 242, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(116, 108, 117, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(116, 108, 117, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant ocean-dark { + --color-screen: #17212b; + --color-sheet: rgba(23, 33, 43, 0.98); + --color-sheet-solid: #17212b; + --color-card: #333b45; + --color-card-alt: #17212b; + --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #8d8f97; + --color-foreground-muted: #969ca6; + --color-foreground-tertiary: #8d8f97; + --color-border: #405567; + --color-border-subtle: rgba(64, 85, 103, 0.7); + --color-separator: rgba(64, 85, 103, 0.55); + --color-subtle: #233544; + --color-subtle-strong: #293f52; + --color-inline-skill-background: #324e66; + --color-inline-skill-border: rgba(112, 185, 238, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #70b9ee; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #293f52; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #405567; + --color-switch-active-track: #70b9ee; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #293f52; + --color-switch-inactive-thumb: #969ca6; + --color-danger: #3c2630; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #333b45; + --color-input-border: #4f677b; + --color-sidebar-search: #424e5a; + --color-placeholder: #a4a4ac; + --color-icon: #fffaff; + --color-icon-muted: #8d8f97; + --color-icon-subtle: #8d8f97; + --color-header: rgba(23, 33, 43, 0.97); + --color-header-border: #36566f; + --color-glass-surface: rgba(65, 72, 81, 0.74); + --color-glass-tint: rgba(65, 72, 81, 0.22); + --color-status-bar: #17212b; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #70b9ee; + --color-md-blockquote-border: #405567; + --color-md-blockquote-bg: #233544; + --color-md-code-bg: #252e38; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #405567; + --color-user-bubble: #375871; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #75d8dd; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(30, 45, 59, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(141, 143, 151, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(141, 143, 151, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant ember-light { + --color-screen: #f9f7f5; + --color-sheet: rgba(249, 247, 245, 0.98); + --color-sheet-solid: #f9f7f5; + --color-card: #f1efee; + --color-card-alt: #f9f7f5; + --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #766c74; + --color-foreground-muted: #74686f; + --color-foreground-tertiary: #766c74; + --color-border: #ddd2ce; + --color-border-subtle: rgba(221, 210, 206, 0.7); + --color-separator: rgba(221, 210, 206, 0.55); + --color-subtle: #f4ede9; + --color-subtle-strong: #f3eae5; + --color-inline-skill-background: #eee0d9; + --color-inline-skill-border: rgba(174, 85, 42, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #ae552a; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #f3eae5; + --color-secondary-foreground: #241523; + --color-secondary-border: #ddd2ce; + --color-switch-active-track: #ae552a; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #f3eae5; + --color-switch-inactive-thumb: #74686f; + --color-danger: #f9e7e6; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #f1efee; + --color-input-border: #d4c6c1; + --color-sidebar-search: #e2d9d6; + --color-placeholder: #736971; + --color-icon: #241523; + --color-icon-muted: #766c74; + --color-icon-subtle: #766c74; + --color-header: rgba(249, 247, 245, 0.97); + --color-header-border: #eee0d9; + --color-glass-surface: rgba(236, 233, 233, 0.74); + --color-glass-tint: rgba(236, 233, 233, 0.22); + --color-status-bar: #f9f7f5; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #ae552a; + --color-md-blockquote-border: #ddd2ce; + --color-md-blockquote-bg: #f4ede9; + --color-md-code-bg: #f3f1f0; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #ddd2ce; + --color-user-bubble: #ebdad1; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #b13535; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(243, 234, 229, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(118, 108, 116, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(118, 108, 116, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant ember-dark { + --color-screen: #291e1a; + --color-sheet: rgba(41, 30, 26, 0.98); + --color-sheet-solid: #291e1a; + --color-card: #433835; + --color-card-alt: #291e1a; + --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #968e8f; + --color-foreground-muted: #a59996; + --color-foreground-tertiary: #968e8f; + --color-border: #664c3f; + --color-border-subtle: rgba(102, 76, 63, 0.7); + --color-separator: rgba(102, 76, 63, 0.55); + --color-subtle: #432e23; + --color-subtle-strong: #513728; + --color-inline-skill-background: #644330; + --color-inline-skill-border: rgba(240, 154, 100, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #f09a64; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #513728; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #664c3f; + --color-switch-active-track: #f09a64; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #513728; + --color-switch-inactive-thumb: #a59996; + --color-danger: #4a2321; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #433835; + --color-input-border: #7a5d4d; + --color-sidebar-search: #584943; + --color-placeholder: #aba3a5; + --color-icon: #fffaff; + --color-icon-muted: #968e8f; + --color-icon-subtle: #968e8f; + --color-header: rgba(41, 30, 26, 0.97); + --color-header-border: #6e4934; + --color-glass-surface: rgba(79, 69, 67, 0.74); + --color-glass-tint: rgba(79, 69, 67, 0.22); + --color-status-bar: #291e1a; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #f09a64; + --color-md-blockquote-border: #664c3f; + --color-md-blockquote-bg: #432e23; + --color-md-code-bg: #362b27; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #664c3f; + --color-user-bubble: #704b34; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #fab6ad; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(57, 40, 31, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(150, 142, 143, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(150, 142, 143, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant iris-light { + --color-screen: #f8f7f9; + --color-sheet: rgba(248, 247, 249, 0.98); + --color-sheet-solid: #f8f7f9; + --color-card: #f0eff2; + --color-card-alt: #f8f7f9; + --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #766c76; + --color-foreground-muted: #726874; + --color-foreground-tertiary: #766c76; + --color-border: #d6d1de; + --color-border-subtle: rgba(214, 209, 222, 0.7); + --color-separator: rgba(214, 209, 222, 0.55); + --color-subtle: #f0edf6; + --color-subtle-strong: #edeaf4; + --color-inline-skill-background: #e5e0f0; + --color-inline-skill-border: rgba(114, 83, 185, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #7253b9; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #edeaf4; + --color-secondary-foreground: #241523; + --color-secondary-border: #d6d1de; + --color-switch-active-track: #7253b9; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #edeaf4; + --color-switch-inactive-thumb: #726874; + --color-danger: #f8e6ea; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #f0eff2; + --color-input-border: #ccc5d6; + --color-sidebar-search: #ddd9e3; + --color-placeholder: #736973; + --color-icon: #241523; + --color-icon-muted: #766c76; + --color-icon-subtle: #766c76; + --color-header: rgba(248, 247, 249, 0.97); + --color-header-border: #e5e0f0; + --color-glass-surface: rgba(235, 233, 237, 0.74); + --color-glass-tint: rgba(235, 233, 237, 0.22); + --color-status-bar: #f8f7f9; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #7253b9; + --color-md-blockquote-border: #d6d1de; + --color-md-blockquote-bg: #f0edf6; + --color-md-code-bg: #f2f1f4; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #d6d1de; + --color-user-bubble: #e0d9ee; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #a82c87; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(237, 234, 244, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(118, 108, 118, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(118, 108, 118, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant iris-dark { + --color-screen: #1d1929; + --color-sheet: rgba(29, 25, 41, 0.98); + --color-sheet-solid: #1d1929; + --color-card: #383443; + --color-card-alt: #1d1929; + --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #8e8a95; + --color-foreground-muted: #9690a1; + --color-foreground-tertiary: #8e8a95; + --color-border: #4d4366; + --color-border-subtle: rgba(77, 67, 102, 0.7); + --color-separator: rgba(77, 67, 102, 0.55); + --color-subtle: #2d2643; + --color-subtle-strong: #362d51; + --color-inline-skill-background: #433765; + --color-inline-skill-border: rgba(157, 125, 242, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #9d7df2; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #362d51; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #4d4366; + --color-switch-active-track: #9d7df2; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #362d51; + --color-switch-inactive-thumb: #9690a1; + --color-danger: #40202e; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #383443; + --color-input-border: #5d527b; + --color-sidebar-search: #494459; + --color-placeholder: #a29ea8; + --color-icon: #fffaff; + --color-icon-muted: #8e8a95; + --color-icon-subtle: #8e8a95; + --color-header: rgba(29, 25, 41, 0.97); + --color-header-border: #4a3c70; + --color-glass-surface: rgba(69, 66, 80, 0.74); + --color-glass-tint: rgba(69, 66, 80, 0.22); + --color-status-bar: #1d1929; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #9d7df2; + --color-md-blockquote-border: #4d4366; + --color-md-blockquote-bg: #2d2643; + --color-md-code-bg: #2a2736; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #4d4366; + --color-user-bubble: #4b3d72; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #f099d8; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(39, 33, 57, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(142, 138, 149, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(142, 138, 149, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css index a42afc74d9..e6961eac4e 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "uniwind"; +@import "./generated-uniwind-themes.css"; /* ─── Theme tokens ──────────────────────────────────────────────────── */ @layer theme { diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index fe88607769..3791d347c6 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { getDefaultConfig } = require("expo/metro-config"); const { withUniwindConfig } = require("uniwind/metro"); +const extraThemes = require("./generated-uniwind-theme-names.json"); /** @type {import("expo/metro-config").MetroConfig} */ const config = getDefaultConfig(__dirname); @@ -50,5 +51,6 @@ config.resolver = { module.exports = withUniwindConfig(config, { cssEntryFile: "./global.css", + extraThemes, polyfills: { rem: 14 }, }); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 1097f6a337..3c378adbc0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,6 +39,7 @@ "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", + "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "test": "vp test run", "typecheck": "tsc --noEmit" }, diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts new file mode 100644 index 0000000000..aa3d9b0bfb --- /dev/null +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import tailwindColors from "tailwindcss/colors"; +import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; + +import { + getMobileThemeVariables, + MOBILE_THEME_VARIABLE_NAMES, + type MobileThemeAppearance, + type MobileThemeVariables, +} from "../src/lib/mobileTheme.ts"; + +const APPEARANCES = ["light", "dark"] as const; +const GLOBAL_CSS_PATH = NodePath.resolve(import.meta.dirname, "../global.css"); +const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css"); +const GENERATED_NAMES_PATH = NodePath.resolve( + import.meta.dirname, + "../generated-uniwind-theme-names.json", +); +const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve( + import.meta.dirname, + "../generated-uniwind-default-theme-variables.json", +); + +type TailwindColorFamily = keyof typeof tailwindColors; +type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; + +const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity = 1): string => { + const familyColors = tailwindColors[family]; + const value = + typeof familyColors === "string" + ? shade === undefined + ? familyColors + : undefined + : shade === undefined + ? undefined + : familyColors[String(shade) as keyof typeof familyColors]; + if (value === undefined) { + throw new Error(`Unknown Tailwind color ${family}${shade === undefined ? "" : `-${shade}`}.`); + } + if (opacity === 1) return value; + + const percentage = Number((opacity * 100).toFixed(4)); + const oklch = /^oklch\((.*)\)$/.exec(value); + if (oklch) return `oklch(${oklch[1]} / ${percentage}%)`; + if (value === "#fff") return `rgb(255 255 255 / ${percentage}%)`; + if (value === "#000") return `rgb(0 0 0 / ${percentage}%)`; + return `color-mix(in srgb, ${value} ${percentage}%, transparent)`; +}; + +// These replace the remaining dark:* utility pairs. A registered palette theme is +// neither literally `light` nor `dark`, so appearance-sensitive values must also be +// represented as semantic variables for custom themes. +const ADAPTIVE_COLORS = { + "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], + "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], + "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)], + "--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)], + "--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)], + "--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)], + "--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)], + "--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)], + "--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)], + "--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)], + "--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)], + "--color-adaptive-black-a10-a25": [ + color("black", undefined, 0.1), + color("black", undefined, 0.25), + ], + "--color-adaptive-black-a15-a35": [ + color("black", undefined, 0.15), + color("black", undefined, 0.35), + ], + "--color-adaptive-emerald-500-a12-a16": [ + color("emerald", 500, 0.12), + color("emerald", 500, 0.16), + ], + "--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)], + "--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)], + "--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)], + "--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)], + "--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)], + "--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)], + "--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)], + "--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)], + "--color-adaptive-neutral-200-a70-white-a8": [ + color("neutral", 200, 0.7), + color("white", undefined, 0.08), + ], + "--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)], + "--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)], + "--color-adaptive-neutral-200-a80-white-a8": [ + color("neutral", 200, 0.8), + color("white", undefined, 0.08), + ], + "--color-adaptive-neutral-300-a60-white-a12": [ + color("neutral", 300, 0.6), + color("white", undefined, 0.12), + ], + "--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)], + "--color-adaptive-neutral-400-a60-500-a60": [ + color("neutral", 400, 0.6), + color("neutral", 500, 0.6), + ], + "--color-adaptive-neutral-400-a80-500-a80": [ + color("neutral", 400, 0.8), + color("neutral", 500, 0.8), + ], + "--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)], + "--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)], + "--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)], + "--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)], + "--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)], + "--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)], + "--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)], + "--color-adaptive-red-200-800": [color("red", 200), color("red", 800)], + "--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)], + "--color-adaptive-red-700-300": [color("red", 700), color("red", 300)], + "--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)], + "--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)], + "--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)], + "--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)], + "--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)], + "--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)], + "--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)], + "--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)], + "--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)], + "--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)], + "--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)], + "--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)], + "--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)], + "--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)], + "--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)], + "--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)], + "--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)], +}; + +export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => + APPEARANCES.map((appearance) => `${themeId}-${appearance}`), +); + +const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => + Object.fromEntries( + Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [ + name, + values[appearance === "light" ? 0 : 1], + ]), + ); + +const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({ + ...getMobileThemeVariables(themeId, appearance), + ...adaptiveVariablesFor(appearance), +}); + +const renderVariant = (name: string, variables: Readonly>) => { + const declarations = Object.entries(variables) + .map(([variable, value]) => ` ${variable}: ${value};`) + .join("\n"); + return ` @variant ${name} {\n${declarations}\n }`; +}; + +export const renderUniwindThemesCSS = () => { + const variants = [ + renderVariant("light", adaptiveVariablesFor("light")), + renderVariant("dark", adaptiveVariablesFor("dark")), + ...BUILT_IN_THEME_IDS.flatMap((themeId) => + APPEARANCES.map((appearance) => + renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)), + ), + ), + ]; + return [ + "/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */", + "@layer theme {", + " :root {", + variants.join("\n\n"), + " }", + "}", + "", + ].join("\n"); +}; + +const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => { + const marker = `@variant ${appearance} {`; + const markerIndex = css.indexOf(marker); + if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`); + + const openingBraceIndex = css.indexOf("{", markerIndex); + let depth = 0; + for (let index = openingBraceIndex; index < css.length; index += 1) { + if (css[index] === "{") depth += 1; + if (css[index] !== "}") continue; + depth -= 1; + if (depth === 0) return css.slice(openingBraceIndex + 1, index); + } + throw new Error(`Could not find the end of ${marker} in global.css.`); +}; + +export const readDefaultThemeVariables = (css: string) => + Object.fromEntries( + APPEARANCES.map((appearance) => { + const body = readVariantBody(css, appearance); + const variables = Object.fromEntries( + MOBILE_THEME_VARIABLE_NAMES.map((name) => { + const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body); + if (!match?.[1]) { + throw new Error(`Default ${appearance} theme is missing ${name}.`); + } + return [name, match[1].trim()]; + }), + ) as MobileThemeVariables; + return [appearance, variables]; + }), + ) as Readonly>; + +export const renderDefaultThemeVariablesJSON = (css: string) => + `${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`; + +export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray< + readonly [filename: string, contents: string] +> => [ + [GENERATED_CSS_PATH, renderUniwindThemesCSS()], + [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`], + [ + GENERATED_DEFAULT_VARIABLES_PATH, + renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")), + ], +]; + +const writeFileAtomically = (filename: string, contents: string) => { + const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; + if (current === contents) return; + + const temporaryFilename = `${filename}.${process.pid}.tmp`; + try { + NodeFS.writeFileSync(temporaryFilename, contents); + NodeFS.renameSync(temporaryFilename, filename); + } finally { + if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename); + } +}; + +if (import.meta.main) { + const checkOnly = process.argv.includes("--check"); + for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) { + if (checkOnly) { + const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; + if (current !== contents) { + console.error( + `${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`, + ); + process.exitCode = 1; + } + continue; + } + // Metro watches the generated CSS. Replacing a complete temporary file keeps + // Tailwind from compiling a partially rewritten theme file. + writeFileAtomically(filename, contents); + } +} diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts new file mode 100644 index 0000000000..48126055ba --- /dev/null +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -0,0 +1,55 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + customThemeNames, + getGeneratedUniwindThemeOutputs, + readDefaultThemeVariables, + renderUniwindThemesCSS, +} from "./generate-uniwind-themes.mts"; + +describe("generate mobile Uniwind themes", () => { + it("keeps the committed outputs current", () => { + const staleOutputs = getGeneratedUniwindThemeOutputs() + .filter( + ([filename, contents]) => + !NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents, + ) + .map(([filename]) => NodePath.relative(import.meta.dirname, filename)); + + expect( + staleOutputs, + "Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.", + ).toEqual([]); + }); + + it("registers every custom palette for both appearances", () => { + expect(customThemeNames).toEqual([ + "t3-chat-light", + "t3-chat-dark", + "grove-light", + "grove-dark", + "ocean-light", + "ocean-dark", + "ember-light", + "ember-dark", + "iris-light", + "iris-dark", + ]); + + const stylesheet = renderUniwindThemesCSS(); + for (const themeName of customThemeNames) { + expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1); + } + }); + + it("generates the default runtime bridge from the authored CSS", () => { + const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8"); + const variables = readDefaultThemeVariables(css); + + expect(variables.light["--color-screen"]).toBe("#f2f2f7"); + expect(variables.dark["--color-screen"]).toBe("#0a0a0a"); + expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); + }); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8b219afcc0..c268056f03 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -21,7 +21,6 @@ import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; -import { useThemeColor } from "./lib/useThemeColor"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import "../global.css"; @@ -72,8 +71,7 @@ export default function App() { function AppContent() { const { themeAppearance } = useAppearancePreferences(); - const statusBarBg = useThemeColor("--color-status-bar"); - const navigationTheme = useMobileNavigationTheme(themeAppearance); + const navigationTheme = useMobileNavigationTheme(); return ( <> @@ -83,7 +81,6 @@ function AppContent() { {/* The navigation theme drives the NATIVE header appearance: native-stack diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 7a27e0c3b1..b4e545fade 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -9,7 +9,6 @@ import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; @@ -84,11 +83,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const isDarkMode = themeAppearance === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); - const rippleColor = useThemeColor("--color-subtle"); - const iconColor = useThemeColor("--color-icon"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const dangerColor = useThemeColor("--color-danger-foreground"); - const close = useCallback(() => { setAnchor(null); setPath([]); @@ -279,10 +273,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return ( onPressItem(action)} @@ -307,21 +300,23 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ) : action.state === "on" ? ( ) : action.image ? ( ) : null} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44f..46bc2c7c09 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -5,7 +5,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; -import { useThemeColor } from "../lib/useThemeColor"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; @@ -20,9 +19,6 @@ export function AndroidHeaderIconButton(props: { readonly onPress?: () => void; readonly disabled?: boolean; }) { - const foregroundColor = useThemeColor("--color-foreground"); - const disabledColor = useThemeColor("--color-icon-subtle"); - return ( @@ -54,7 +50,6 @@ export function AndroidScreenHeader(props: { readonly embedded?: boolean; }) { const insets = useSafeAreaInsets(); - const foregroundColor = useThemeColor("--color-foreground"); return ( diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index ef39359d4c..12755bc1ae 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -82,6 +82,7 @@ import { } from "@tabler/icons-react-native"; import { Platform } from "react-native"; import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols"; +import { withUniwind } from "uniwind"; const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, @@ -192,7 +193,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { export type { SFSymbol } from "expo-symbols"; export type AppSymbolName = SymbolViewProps["name"]; -export function SymbolView(props: SymbolViewProps) { +function AppSymbolView(props: SymbolViewProps) { if (Platform.OS !== "android") { return ; } @@ -218,3 +219,11 @@ export function SymbolView(props: SymbolViewProps) { /> ); } + +/** + * expo-symbols and the Android Tabler fallback both expose tint as a native + * prop rather than a React Native style. Keep that third-party boundary here + * so callers can use Uniwind's `tintColorClassName` instead of subscribing to + * theme variables in every parent component. + */ +export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index bfba418c9f..9419a74e5f 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -9,7 +9,6 @@ import { AppText as Text } from "./AppText"; import { T3Wordmark } from "./T3Wordmark"; import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; -import { useThemeColor } from "../lib/useThemeColor"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; // Native leading items inherit different UIKit margins than title views. @@ -37,9 +36,6 @@ export function CompactBrandTitle( readonly nativeLeadingItem?: boolean; } = {}, ) { - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); @@ -49,42 +45,20 @@ export function CompactBrandTitle( accessibilityLabel="T3 Code, Threads" accessible role="heading" - style={{ - alignItems: "center", - flexDirection: "row", - gap: 6, - marginLeft: titleOffset, - }} + className="flex-row items-center gap-1.5" + style={{ marginLeft: titleOffset }} > - + Code - + {stageLabel} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 0621285c03..e3d42ca9cb 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,6 +1,5 @@ import { SymbolView } from "../components/AppSymbol"; import { Image, Pressable, ScrollView, View } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; @@ -24,7 +23,6 @@ export interface ComposerAttachmentStripProps { * buttons. Used by both the thread composer and the new-task draft screen. */ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { - const subtleBg = useThemeColor("--color-subtle"); const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; @@ -56,11 +54,11 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { > diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index de0af576ec..b64e44b193 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -11,9 +11,6 @@ import { type ViewStyle, } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { themeColorWithAlpha } from "../lib/mobileTheme"; import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; import { SymbolView } from "./AppSymbol"; @@ -42,10 +39,6 @@ export function ComposerInlineControl(props: { readonly chevronDirection?: "down" | "right"; readonly showChevron?: boolean; }) { - const iconColor = useThemeColor( - props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted", - ); - return ( {props.iconNode} ) : props.icon ? ( - + ) : null} )} @@ -111,8 +113,12 @@ export function ComposerToolbarRow(props: { export function ComposerToolbarScroller(props: { readonly children: ReactNode; - readonly fadeOpaque: string; - readonly fadeTransparent: string; + /** Only for non-Uniwind surfaces such as the native terminal palette. */ + readonly fadeOpaque?: string; + /** Only for non-Uniwind surfaces such as the native terminal palette. */ + readonly fadeTransparent?: string; + /** Semantic Uniwind surface behind the toolbar. Defaults to card. */ + readonly fadeSurface?: "card" | "sheet"; readonly contentPaddingRight?: number; }) { const [metrics, setMetrics] = useState({ @@ -170,27 +176,37 @@ export function ComposerToolbarScroller(props: { {scrollEdges.showLeftFade ? ( ) : null} {scrollEdges.showRightFade ? ( ) : null} @@ -214,30 +230,16 @@ export function ComposerToolbarButton(props: { readonly className?: string; readonly style?: StyleProp; }) { - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const iconColor = useThemeColor("--color-icon"); - const iconSubtle = useThemeColor("--color-icon-subtle"); - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); const variant = props.variant ?? "default"; const isCircle = !props.label && props.showChevron === false; - const defaultBorderColor = useThemeColor("--color-border-subtle"); - const activeBorderColor = useThemeColor("--color-border"); - const filledBorderColor = - variant === "danger" - ? themeColorWithAlpha(String(dangerFg), 0.14) - : props.disabled - ? defaultBorderColor - : themeColorWithAlpha(String(primaryFg), 0.18); - const iconTintColor = + const iconTintClassName = variant === "primary" ? props.disabled - ? iconSubtle - : primaryFg + ? "accent-icon-subtle" + : "accent-primary-foreground" : variant === "danger" - ? dangerFg - : iconColor; + ? "accent-danger-foreground" + : "accent-icon"; return ( [ { - borderColor: - variant === "default" - ? props.active - ? activeBorderColor - : defaultBorderColor - : filledBorderColor, - borderWidth: 1, maxWidth: props.maxWidth, minWidth: props.minWidth, opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, - shadowColor: "#000", - shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 }, - shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08, - shadowRadius: isDarkMode ? 10 : 8, }, props.style, ]} @@ -286,7 +287,12 @@ export function ComposerToolbarButton(props: { {props.iconNode ? ( {props.iconNode} ) : props.icon ? ( - + ) : null} {props.label ? ( ) : null} {props.showChevron === false ? null : ( - + )} ); diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 81daa3d6a2..521c5e36c3 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Modal, Pressable, View } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -35,8 +34,6 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { */ export function ConfirmDialogHost() { const [request, setRequest] = useState(null); - const pressedOverlay = useThemeColor("--color-subtle"); - useEffect(() => { presentRequest = setRequest; return () => { @@ -76,8 +73,7 @@ export function ConfirmDialogHost() { @@ -88,8 +84,7 @@ export function ConfirmDialogHost() { {props.iconNode} ) : props.icon ? ( - + ) : null} {props.label ? {props.label} : null} diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 76e06edcd1..6c12c9bdd8 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,10 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - - {props.message} - + + {props.message} ); } diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx index 8f91d61031..16a5fc4a47 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../lib/useThemeColor"; import { GlassSurface } from "./GlassSurface"; @@ -19,23 +18,13 @@ export function GlassSafeAreaView({ style, }: GlassSafeAreaViewProps) { const insets = useSafeAreaInsets(); - const headerColor = useThemeColor("--color-header"); - const headerBorderColor = useThemeColor("--color-header-border"); - const glassTint = useThemeColor("--color-glass-tint"); const headerPaddingTop = insets.top + 16; - const surfaceStyle = { - borderRadius: 0, - backgroundColor: headerColor, - borderBottomWidth: 1, - borderBottomColor: headerBorderColor, - } as const; return ( - + { +import { cn } from "../lib/cn"; + +// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. +const ThemedGlassView = withUniwind(GlassView, { + style: { fromClassName: "className" }, + tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, +}); + +interface GlassSurfaceProps extends ViewProps { readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; + readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; /** Styling used only when native Liquid Glass is unavailable. */ readonly fallbackStyle?: StyleProp; + /** Uniwind styling used only when native Liquid Glass is unavailable. */ + readonly fallbackClassName?: string; } export function GlassSurface({ @@ -25,22 +36,18 @@ export function GlassSurface({ glassEffectStyle = "regular", chrome = "default", tintColor, + tintColorClassName, fallbackStyle, + fallbackClassName, + className, style, ...props }: GlassSurfaceProps) { - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const borderColor = useThemeColor("--color-border"); - const glassSurface = useThemeColor("--color-glass-surface"); - const glassTint = useThemeColor("--color-glass-tint"); + const isDarkMode = useColorScheme() === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); const surfaceStyle: ViewStyle = { borderRadius: 32, overflow: "hidden", - borderWidth: chrome === "none" ? 0 : 1, - borderColor: chrome === "none" ? "transparent" : borderColor, - backgroundColor: chrome === "none" ? "transparent" : glassSurface, shadowColor: chrome === "none" ? "transparent" : "#000000", shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, shadowRadius: chrome === "none" ? 0 : 28, @@ -59,20 +66,39 @@ export function GlassSurface({ if (supportsGlass) { return ( - {children} - + ); } return ( - + {children} ); diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx index 275381a9c9..456a347d36 100644 --- a/apps/mobile/src/components/LoadingScreen.tsx +++ b/apps/mobile/src/components/LoadingScreen.tsx @@ -1,6 +1,5 @@ import { ActivityIndicator, StatusBar, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../lib/useThemeColor"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./AppText"; @@ -11,17 +10,12 @@ export function LoadingScreen(props: { readonly messagePlacement?: "above-spinner" | "below-spinner"; }) { const { themeAppearance: colorScheme } = useAppearancePreferences(); - const screenBg = useThemeColor("--color-screen"); const insets = useSafeAreaInsets(); const messagePlacement = props.messagePlacement ?? "below-spinner"; return ( - + {messagePlacement === "above-spinner" ? ( diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index 9cb6898fb9..cb73f5b7b1 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -3,7 +3,6 @@ import { Image, type ImageStyle, type StyleProp } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; -import { useThemeColor } from "../lib/useThemeColor"; export function PierreEntryIcon(props: { readonly path: string; @@ -12,9 +11,15 @@ export function PierreEntryIcon(props: { readonly style?: StyleProp; }) { const size = props.size ?? 16; - const folderColor = useThemeColor("--color-icon-subtle"); if (props.kind === "directory") { - return ; + return ( + + ); } return ( diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c4297f24b0..c60709baf4 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -7,7 +7,6 @@ import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; import { beginProjectFaviconRequest, @@ -62,7 +61,6 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const iconMuted = useThemeColor("--color-icon-subtle"); const faviconRequest = useMemo( () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), [props.cacheKey, props.faviconUrl], @@ -97,7 +95,7 @@ function ProjectFaviconImage(props: { ) : null} diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index b1d4918037..3b371c021a 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,4 +1,7 @@ import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedSvg = withUniwind(Svg); export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; @@ -6,20 +9,28 @@ export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; readonly size?: number; readonly color?: string; + readonly colorClassName?: string; }) { const size = props.size ?? 18; switch (props.kind) { case "github": return ( - + - + ); case "gitlab": return ( diff --git a/apps/mobile/src/components/T3Wordmark.tsx b/apps/mobile/src/components/T3Wordmark.tsx index 81106557c6..5f64effffc 100644 --- a/apps/mobile/src/components/T3Wordmark.tsx +++ b/apps/mobile/src/components/T3Wordmark.tsx @@ -1,11 +1,18 @@ import type { ColorValue } from "react-native"; import Svg, { Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedPath = withUniwind(Path); /** * The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG * (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio. */ -export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) { +export function T3Wordmark(props: { + readonly height: number; + readonly color?: ColorValue; + readonly colorClassName?: string; +}) { const aspectRatio = 94.3941 / 56.96; return ( - ); diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 270ee084e4..5b4603fd12 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,21 +1,19 @@ import { Platform, Switch, type SwitchProps } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; - export function ThemedSwitch(props: SwitchProps) { - const activeTrack = String(useThemeColor("--color-switch-active-track")); - const inactiveTrack = String(useThemeColor("--color-switch-inactive-track")); - const activeThumb = String(useThemeColor("--color-switch-active-thumb")); - const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb")); - return ( ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 801862086b..5b61d302a7 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -27,7 +27,7 @@ import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, @@ -70,8 +70,6 @@ function ArchivedThreadsHeader(props: { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; - const searchIconColor = useThemeColor("--color-icon"); - const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; const usesCompactMailToolbar = Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; @@ -154,7 +152,7 @@ function ArchivedThreadsHeader(props: { @@ -162,7 +160,7 @@ function ArchivedThreadsHeader(props: { @@ -402,9 +400,7 @@ function ArchivedThreadRow(props: { readonly thread: EnvironmentThreadShell; }) { const { width: windowWidth } = useWindowDimensions(); - const cardColor = useThemeColor("--color-card"); - const iconColor = useThemeColor("--color-icon-subtle"); - const separatorColor = useThemeColor("--color-separator"); + const cardColor = useUniwindTheme()["--color-card"]; const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), @@ -436,14 +432,15 @@ function ArchivedThreadRow(props: { > {() => ( - + @@ -463,7 +460,7 @@ function ArchivedThreadRow(props: { (null); const archiveScrollGesture = useMemo(() => Gesture.Native(), []); - const refreshTint = useThemeColor("--color-icon"); const environmentLabelsById = useMemo( () => new Map( @@ -594,7 +590,7 @@ export function ArchivedThreadsScreen(props: { if (isInitialLoad) { return ( - + Loading archive... ); @@ -610,7 +606,7 @@ export function ArchivedThreadsScreen(props: { title={isFiltered ? "No matching threads" : "No archived threads"} /> ); - }, [isFiltered, isInitialLoad, refreshTint]); + }, [isFiltered, isInitialLoad]); return ( @@ -649,7 +645,7 @@ export function ArchivedThreadsScreen(props: { } renderItem={renderListItem} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 4d7b086418..42aa8ffebb 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; @@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({ })); vi.mock("expo-device", () => ({ + deviceType: 1, + DeviceType: { + UNKNOWN: 0, + PHONE: 1, + TABLET: 2, + DESKTOP: 3, + TV: 4, + }, osVersion: "18.4.1", modelName: "iPhone 15 Pro", })); @@ -1081,13 +1089,88 @@ describe("mobile cloud link environment client", () => { ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, traceId: "trace-connect", }); }), ); + it.effect( + "presents clock skew as one possible cause when an older environment rejects DPoP", + () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + const value = String(url); + if (value.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json(validDpopAccessTokenResponse("environment:connect")), + ); + } + if (value.endsWith("/v1/environments/env-1/connect")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + } + if (value.endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + return Promise.resolve( + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-environment", + }, + { status: 401 }, + ), + ); + }), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-environment", + }); + }), + ); + it.effect("rejects relay connect responses for a different endpoint", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 958827ee49..c2033117f6 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, + EnvironmentAuthInvalidError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, EnvironmentHttpForbiddenError, @@ -17,7 +18,6 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, type RelayDpopAccessTokenScope, - type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, @@ -25,7 +25,11 @@ import { import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + dpopFailureMessage, + ManagedRelay, + relayProtectedErrorMessage, +} from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is( EnvironmentCloudEndpointUnavailableError, ]), ); +const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; -function cloudEnvironmentLinkError(message: string) { +function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); const traceId = findErrorTraceId(cause); + const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null; + const detail = environmentError + ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` + : withDevCause(message, cause); return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause), + message: + dpopAuthError?.reason === "invalid_credential" + ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason) + : detail, cause, ...(traceId === null ? {} : { traceId }), }); @@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string { return detail ? `${message} (${detail})` : message; } -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = @@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } +function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null { + if (isEnvironmentAuthInvalidError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null; +} + function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); return relayUrl @@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", { + dpop: true, + }), ), ); const pairingUrl = new URL(connect.endpoint.httpBaseUrl); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 6da73eaeb1..4c840636c9 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -18,7 +18,6 @@ import { AppText as Text } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -78,7 +77,6 @@ function CloudEnvironmentRowsContent( props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, ) { const controller = useConnectionController(); - const iconColor = useThemeColor("--color-icon"); const discoveryAvailable = props.discoveryAvailable ?? true; const availableCloudEnvironments = discoveryAvailable ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) @@ -118,12 +116,12 @@ function CloudEnvironmentRowsContent( className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" > {controller.relayDiscovery.isRefreshing ? ( - + ) : ( )} @@ -158,7 +156,7 @@ function CloudEnvironmentRowsContent( ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. @@ -275,7 +273,6 @@ function CloudEnvironmentRowShell(props: { readonly statusText?: string; readonly value: boolean; }) { - const chevron = useThemeColor("--color-chevron"); const isRetrying = props.connectionState === "connecting" || props.connectionState === "reconnecting"; const shouldPulse = isRetrying; @@ -287,7 +284,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-rose-500 dark:text-rose-400" + ? "text-adaptive-rose-500-400" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; @@ -377,7 +374,7 @@ function CloudEnvironmentRowShell(props: { - + Copy trace ID ); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 03a0eb5025..86ebc6c11a 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -7,7 +7,6 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; import { Alert, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -36,10 +35,6 @@ export function ConnectionEnvironmentRow(props: { }) { const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); - - const mutedColor = useThemeColor("--color-icon-subtle"); - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = props.environment.connectionErrorTraceId; const hasConnectionFailure = props.environment.connectionError !== null; @@ -85,7 +80,7 @@ export function ConnectionEnvironmentRow(props: { - + Save @@ -188,7 +188,7 @@ export function ConnectionEnvironmentRow(props: { @@ -197,7 +197,12 @@ export function ConnectionEnvironmentRow(props: { className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70" onPress={() => props.onRemove(props.environment.environmentId)} > - + diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index f88e328744..fe26c66a35 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -1,6 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; import { Platform, Pressable } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -37,11 +36,12 @@ export function ConnectionSheetButton(props: { }) { const tone = props.tone ?? "secondary"; - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryFg = useThemeColor("--color-secondary-foreground"); - - const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; + const textColorClassName = + tone === "primary" + ? "accent-primary-foreground" + : tone === "danger" + ? "accent-danger-foreground" + : "accent-secondary-foreground"; const primaryShadow = tone === "primary" @@ -79,7 +79,7 @@ export function ConnectionSheetButton(props: { (null); - const headerIconColor = useThemeColor("--color-icon"); + const headerIconColor = useUniwindTheme()["--color-icon"]; const connectDisabled = isSubmitting || hostInput.trim().length === 0; diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 464477ffc8..88d4e2d4be 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -5,7 +5,6 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; @@ -24,9 +23,6 @@ export function ConnectionsRouteScreen() { const insets = useSafeAreaInsets(); const hasEnvironments = connectedEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - - const accentColor = useThemeColor("--color-icon-muted"); - const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); @@ -89,7 +85,7 @@ export function ConnectionsRouteScreen() { diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index ce7e7bec96..4bb15fc987 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -7,7 +7,6 @@ import { ActivityIndicator, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { useThemeColor } from "../../lib/useThemeColor"; function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { switch (phase) { @@ -55,7 +54,6 @@ export function EnvironmentConnectionNotice(props: { readonly resourceName: string; readonly onRetry: () => void; }) { - const iconColor = String(useThemeColor("--color-icon-muted")); const isRetrying = props.connection.phase === "connecting" || props.connection.phase === "reconnecting"; @@ -63,12 +61,12 @@ export function EnvironmentConnectionNotice(props: { {isRetrying ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 0de49ceabf..51ee592c2c 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -6,38 +6,38 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { case "connected": return { label: "Connected", - pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", - textClassName: "text-emerald-700 dark:text-emerald-300", + pillClassName: "bg-adaptive-emerald-500-a12-a16", + textClassName: "text-adaptive-emerald-700-300", }; case "reconnecting": return { label: "Reconnecting", - pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", - textClassName: "text-amber-700 dark:text-amber-300", + pillClassName: "bg-adaptive-amber-500-a12-a16", + textClassName: "text-adaptive-amber-700-300", }; case "connecting": return { label: "Connecting", - pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", - textClassName: "text-sky-700 dark:text-sky-300", + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-700-300", }; case "error": return { label: "Connection failed", - pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", - textClassName: "text-rose-700 dark:text-rose-300", + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", }; case "offline": return { label: "Offline", - pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", - textClassName: "text-rose-700 dark:text-rose-300", + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", }; case "available": return { label: "Available", - pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", - textClassName: "text-neutral-600 dark:text-neutral-300", + pillClassName: "bg-adaptive-neutral-500-a10-a16", + textClassName: "text-adaptive-neutral-600-300", }; } } diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index c9788d906f..4c581d7dba 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -14,7 +14,7 @@ import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasNativeSelectableMarkdownText, @@ -40,14 +40,15 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const body = String(useThemeColor("--color-md-body")); - const strong = String(useThemeColor("--color-md-strong")); - const link = String(useThemeColor("--color-md-link")); - const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); - const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg")); - const codeBackground = String(useThemeColor("--color-md-code-bg")); - const codeText = String(useThemeColor("--color-md-code-text")); - const horizontalRule = String(useThemeColor("--color-md-hr")); + const theme = useUniwindTheme(); + const body = theme["--color-md-body"]; + const strong = theme["--color-md-strong"]; + const link = theme["--color-md-link"]; + const blockquoteBorder = theme["--color-md-blockquote-border"]; + const blockquoteBackground = theme["--color-md-blockquote-bg"]; + const codeBackground = theme["--color-md-code-bg"]; + const codeText = theme["--color-md-code-text"]; + const horizontalRule = theme["--color-md-hr"]; const regularFontFamily = useFontFamily("regular"); const mediumFontFamily = useFontFamily("medium"); const boldFontFamily = useFontFamily("bold"); diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index f89bea1330..bce58d838a 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -7,7 +7,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { @@ -46,7 +45,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly iconColor: string; readonly onPressDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; @@ -79,7 +77,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { ) : ( @@ -125,7 +123,6 @@ export function FileTreeBrowser(props: { // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; - const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); @@ -216,13 +213,12 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - iconColor={iconColor} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} /> ), - [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory], + [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], ); if (props.error && props.entries.length === 0) { diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx index 09e1810617..0035dcc12a 100644 --- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx +++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx @@ -2,18 +2,10 @@ import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown- import { useState } from "react"; import { ScrollView, Text as NativeText, View } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; - const MIN_KEY_COLUMN_WIDTH = 160; const MIN_VALUE_COLUMN_WIDTH = 400; -function MarkdownFrontmatterList({ - items, - textColor, -}: { - readonly items: ReadonlyArray; - readonly textColor: string; -}) { +function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray }) { const occurrences = new Map(); return ( @@ -27,9 +19,7 @@ function MarkdownFrontmatterList({ key={JSON.stringify([item, occurrence])} className="max-w-full rounded-full border border-secondary-border bg-secondary px-2.5 py-1" > - - {item} - + {item} ); })} @@ -42,9 +32,6 @@ export function MarkdownFrontmatterTable({ }: { readonly entries: ReadonlyArray; }) { - const textColor = String(useThemeColor("--color-md-body")); - const strongColor = String(useThemeColor("--color-md-strong")); - const codeColor = String(useThemeColor("--color-md-code-text")); const [keyWidths, setKeyWidths] = useState>(() => new Map()); let measuredKeyColumnWidth = MIN_KEY_COLUMN_WIDTH; let hasEveryKeyWidth = true; @@ -67,7 +54,9 @@ export function MarkdownFrontmatterTable({ > {entries.map((entry, index) => ( - + {entry.key} {entry.value.kind === "text" ? ( - + {entry.value.text} ) : entry.value.kind === "list" ? ( - + ) : ( - + {entry.value.source} )} diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 942d0b4ffb..2eabce998e 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -17,6 +17,7 @@ import { cn } from "../../lib/cn"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, @@ -153,6 +154,7 @@ function NativeSourceFileSurface( const { NativeView, onRefresh } = props; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); const [isPullRefreshing, setIsPullRefreshing] = useState(false); @@ -173,8 +175,8 @@ function NativeSourceFileSurface( [targetIndex], ); const themeJson = useMemo( - () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)), - [themeAppearance, themeId], + () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)), + [appTheme, themeAppearance, themeId], ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); const contentWidth = codeWordBreak diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 28356be185..462d075324 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,6 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useRef, useState } from "react"; +import type { MenuAction } from "@react-native-menu/menu"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -11,15 +12,16 @@ import { ThreadId, } from "@t3tools/contracts"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -135,11 +137,11 @@ function FileContent(props: { return ( {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. @@ -210,7 +212,7 @@ function FilesUnavailable() { } function FilesToolbarBottomFade() { - const sheetColor = String(useThemeColor("--color-sheet")); + const sheetColor = String(useUniwindTheme()["--color-sheet"]); if (process.env.EXPO_OS !== "ios") { return null; @@ -245,8 +247,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const [searchQuery, setSearchQuery] = useState(""); const isAndroid = Platform.OS === "android"; const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const iconColor = String(useThemeColor("--color-icon-muted")); - const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid")); + const theme = useUniwindTheme(); + const sheetSurfaceColor = theme["--color-sheet-solid"]; const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -413,7 +415,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ]} /> - + { + if (relativePath === null) return []; + const canToggleMode = canPreview && !isImageFile; + return [ + canToggleMode + ? ({ + id: "preview", + title: "Preview", + icon: "eye", + inline: true, + onPress: () => setModeOverride({ path: relativePath, mode: "preview" }), + } as const) + : null, + canToggleMode + ? ({ + id: "source", + title: "Source", + icon: "doc.text", + inline: true, + onPress: () => setModeOverride({ path: relativePath, mode: "source" }), + } as const) + : null, + { + id: "copy-path", + title: "Copy path", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(relativePath), + } as const, + isBrowserFile && typeof assetPreviewUri === "string" + ? ({ + id: "open-browser", + title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser", + icon: "safari", + inline: false, + onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), + } as const) + : null, + resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) + ? ({ + id: "refresh", + title: "Refresh", + icon: "arrow.clockwise", + inline: false, + onPress: () => setPreviewRevision((current) => current + 1), + } as const) + : null, + ].filter((action) => action !== null); + }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]); + + const androidFileMenuActions = useMemo( + () => + fileMenuActions.map((action) => ({ + id: action.id, + title: action.title, + image: action.icon, + state: action.id === resolvedActiveMode ? "on" : undefined, + })), + [fileMenuActions, resolvedActiveMode], + ); + const handleAndroidFileMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event); + void action?.onPress(); + }, + [fileMenuActions], + ); + const handleReturnToThread = useCallback(() => { + if (environmentId !== null && threadId !== null) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ); + } + }, [environmentId, navigation, threadId]); + const handleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + handleReturnToThread(); + }, [handleReturnToThread, navigation]); + if (selectedThread === null || environmentId === null || threadId === null) { return ; } @@ -577,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid // sheet-colored header — this route's content scrolls internally, so // there is nothing for glass to sample). Only dynamic values here. + headerShown: !isAndroid, headerTintColor: iconColor, headerTitle: basename(relativePath), title: basename(relativePath), @@ -584,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {isAndroid ? ( + + {fileInspector.supported ? ( + + ) : null} + + + + + } + /> + ) : null} {fileInspector.supported ? ( { - navigation.dispatch( - StackActions.replace("Thread", { - environmentId: String(environmentId), - threadId: String(threadId), - }), - ); - }} + onPress={handleReturnToThread} /> ) : null} @@ -612,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { /> ) : null} - {canPreview && !isImageFile ? ( + {fileMenuActions.some(({ inline }) => inline) ? ( + {fileMenuActions + .filter(({ inline }) => inline) + .map((action) => ( + + {action.title} + + ))} + + ) : null} + {fileMenuActions + .filter(({ inline }) => !inline) + .map((action) => ( setModeOverride({ path: relativePath, mode: "preview" })} - > - Preview - - setModeOverride({ path: relativePath, mode: "source" })} + key={action.id} + icon={action.icon} + onPress={action.onPress} > - Source + {action.title} - - ) : null} - copyTextWithHaptic(relativePath)} - > - Copy path - - {isBrowserFile && typeof assetPreviewUri === "string" ? ( - { - void tryOpenExternalUrl(assetPreviewUri, "file-preview"); - }} - > - Open in Safari - - ) : null} - {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( - { - setPreviewRevision((current) => current + 1); - }} - > - Refresh - - ) : null} + ))} - + - + {props.children} @@ -42,7 +39,7 @@ function AndroidHomeFab(props: { diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e7ce41cb43..cbeadf59f6 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -12,7 +12,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../../lib/mobileBranding"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -67,8 +67,6 @@ function checkedMenuState(checked: boolean) { function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and @@ -218,7 +216,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { brand={ {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - + Code @@ -248,7 +246,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { : "line.3.horizontal.decrease.circle" } size={16} - tintColor={iconColor} + tintColorClassName={"accent-icon"} type="monochrome" /> @@ -262,12 +260,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) { onPress={props.onOpenSettings} className="size-11 items-center justify-center rounded-full bg-subtle" > - + - + @@ -300,7 +308,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); - const iconColor = useThemeColor("--color-icon"); + const iconColor = useUniwindTheme()["--color-icon"]; // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and // key the "customized" icon state off the environment filter alone. diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3a92131bb2..57cbb9125e 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -23,7 +23,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; @@ -216,7 +215,6 @@ export function HomeScreen(props: HomeScreenProps) { const openSwipeableRef = useRef(null); const listRef = useRef(null); const insets = useSafeAreaInsets(); - const accentColor = useThemeColor("--color-icon-muted"); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT @@ -1088,7 +1086,7 @@ export function HomeScreen(props: HomeScreenProps) { /> {emptyState.loading ? ( - + ) : null} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 1867042988..d4aaeb4de4 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -8,7 +8,6 @@ import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-na import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { useThemeColor } from "../../lib/useThemeColor"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useWorkspaceState } from "../../state/workspace"; import { @@ -98,7 +97,6 @@ export function WorkspaceConnectionTitle(props: { /** Horizontal correction so the status aligns with the brand in native title slots. */ readonly statusOffset?: number; }) { - const iconColor = String(useThemeColor("--color-icon-muted")); const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; @@ -125,12 +123,12 @@ export function WorkspaceConnectionTitle(props: { style={{ marginLeft: props.statusOffset ?? 0 }} > {status.showsProgress ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 66ce2a0aaf..6982b5cb4c 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -2,15 +2,17 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { - const iconColor = useThemeColor("--color-icon-subtle"); - return ( - + Select a thread Choose a thread from the sidebar or start a new task. diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index d476452efa..6396628226 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { cn } from "../../lib/cn"; const ACCESSIBILITY_RESIZE_STEP = 24; @@ -22,8 +22,6 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { latestProps.current = props; const [hovered, setHovered] = useState(false); const [dragging, setDragging] = useState(false); - const dividerColor = useThemeColor("--color-border"); - const activeDividerColor = useThemeColor("--color-primary"); const handleResizeStart = useCallback(() => { setDragging(true); latestProps.current.onResizeStart?.(); @@ -81,11 +79,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { onHoverOut={() => setHovered(false)} > @@ -96,11 +94,9 @@ const styles = StyleSheet.create({ line: { alignSelf: "center", height: "100%", - opacity: 0.7, width: StyleSheet.hairlineWidth, }, activeLine: { - opacity: 1, width: 2, }, }); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd..a82f693737 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -51,7 +51,6 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; -import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -159,8 +158,6 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { - const chevronColor = useThemeColor("--color-chevron"); - return ( + ) : null} @@ -205,8 +207,6 @@ function PrimaryActionButton(props: { readonly loading?: boolean; readonly onPress: () => void; }) { - const primaryForeground = useThemeColor("--color-primary-foreground"); - return ( {props.loading ? ( - + ) : ( {props.label} )} @@ -414,7 +414,6 @@ function SourceControlRow(props: { readonly isFirst: boolean; }) { const navigation = useNavigation(); - const iconColor = useThemeColor("--color-icon"); const title = props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; const subtitle = @@ -423,9 +422,9 @@ function SourceControlRow(props: { : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; const icon = props.source === "url" ? ( - + ) : ( - + ); if (!props.ready) { @@ -454,8 +453,6 @@ function SourceControlRow(props: { export function AddProjectSourceScreen() { const navigation = useNavigation(); - const accentColor = useThemeColor("--color-icon-muted"); - const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); const discoveryState = useEnvironmentQuery( @@ -496,7 +493,7 @@ export function AddProjectSourceScreen() { } @@ -508,7 +505,7 @@ export function AddProjectSourceScreen() { ) : null @@ -530,7 +527,7 @@ export function AddProjectSourceScreen() { } @@ -560,7 +557,9 @@ export function AddProjectSourceScreen() { ), )} - {discoveryState.isPending ? : null} + {discoveryState.isPending ? ( + + ) : null} ) : null} @@ -745,7 +744,6 @@ function FolderBrowser(props: { }) => Promise; readonly pinnedDirectoryName?: string; }) { - const accentColor = useThemeColor("--color-icon-muted"); const browsePath = useMemo( () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], @@ -781,7 +779,7 @@ function FolderBrowser(props: { {browseState.isPending && browseState.data === null ? ( - + ) : null} {browsePath.canBrowseUp ? ( @@ -791,7 +789,7 @@ function FolderBrowser(props: { } @@ -810,7 +808,14 @@ function FolderBrowser(props: { } + icon={ + + } isFirst={index === 0 && !browsePath.canBrowseUp} right={null} onPress={() => { diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 40f8fcf153..a798e5ebe4 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -14,7 +14,6 @@ import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useNativePaste } from "../../lib/useNativePaste"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state"; @@ -46,7 +45,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); - const iconTint = String(useThemeColor("--color-icon")); const target = useReviewCommentTarget(); const { codeSurface } = useAppearanceCodeSurface(); const { environmentId, threadId } = props.route.params; @@ -168,7 +166,12 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp className="bg-subtle h-12 w-12 items-center justify-center rounded-full" onPress={dismissComposer} > - + Add Comment diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 0524371738..80ebe1157d 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -37,7 +37,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; @@ -80,13 +80,11 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - - + + Partial diff - - {props.notice} - + {props.notice} ); }); @@ -97,7 +95,6 @@ function ReviewSelectionActionBar(props: { readonly onOpenComment: (() => void) | null; readonly onClear: () => void; }) { - const foreground = useThemeColor("--color-primary-foreground"); if (!props.title) { return null; } @@ -107,7 +104,7 @@ function ReviewSelectionActionBar(props: { {props.title} @@ -144,7 +141,12 @@ function ReviewSelectionActionBar(props: { className="h-12 w-12 items-center justify-center rounded-full bg-primary" onPress={props.onClear} > - + ); @@ -217,8 +219,9 @@ function ReviewFileNavigator({ ref, }: ReviewFileNavigatorProps) { const insets = useSafeAreaInsets(); - const sheetColor = String(useThemeColor("--color-sheet")); - const foregroundColor = String(useThemeColor("--color-foreground")); + const theme = useUniwindTheme(); + const sheetColor = theme["--color-sheet"]; + const foregroundColor = theme["--color-foreground"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const [fileSelection, setFileSelection] = useState<{ readonly sectionId: string | null; @@ -348,7 +351,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); - const headerIcon = String(useThemeColor("--color-icon")); + const headerIcon = String(useUniwindTheme()["--color-icon"]); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index dbd1d7aeb0..3a291b2e78 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeVariables, + MOBILE_THEME_IDS, + type MobileThemeAppearance, + type MobileThemeId, +} from "../../lib/mobileTheme"; +import { readDefaultMobileThemeVariables } from "../../lib/mobileTheme.test-support"; import { createNativeReviewDiffTheme, @@ -39,6 +46,12 @@ function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) { return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput; } +function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { + return themeId === DEFAULT_MOBILE_THEME_ID + ? readDefaultMobileThemeVariables(appearance) + : getMobileThemeVariables(themeId, appearance); +} + describe("getCachedNativeReviewDiffData", () => { it("reuses the row model for equivalent empty comment arrays", () => { const first = getCachedNativeReviewDiffData(buildInput([])); @@ -61,7 +74,11 @@ describe("createNativeReviewDiffTheme", () => { it("serializes every native color as cross-platform opaque hex", () => { for (const themeId of MOBILE_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { - const theme = createNativeReviewDiffTheme(appearance, themeId); + const theme = createNativeReviewDiffTheme( + appearance, + themeId, + appTheme(themeId, appearance), + ); for (const color of Object.values(theme)) { expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i); } @@ -70,8 +87,8 @@ describe("createNativeReviewDiffTheme", () => { }); it("uses the selected app palette for native code surfaces", () => { - const standard = createNativeReviewDiffTheme("dark", "t3-code"); - const iris = createNativeReviewDiffTheme("dark", "iris"); + const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); + const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); expect(iris.background).not.toBe(standard.background); expect(iris.hunkText).not.toBe(standard.hunkText); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 66beae22e9..a45a955d33 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -8,11 +8,7 @@ import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; -import { - DEFAULT_MOBILE_THEME_ID, - getMobileThemeVariables, - type MobileThemeId, -} from "../../lib/mobileTheme"; +import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; import { @@ -137,10 +133,10 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray>( () => new Set(), ); - const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]); + const theme = useMemo( + () => createNativeReviewDiffTheme(scheme, themeId, appTheme), + [appTheme, scheme, themeId], + ); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 9e18d4675f..3480340f40 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,12 +1,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { SymbolView } from "expo-symbols"; import { useMemo } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { SymbolView } from "../../components/AppSymbol"; import { clearClientCacheAtom, clientCacheSummaryAtom, @@ -17,8 +16,6 @@ import { SettingsSection } from "./components/SettingsSection"; export function SettingsClientStorageRouteScreen() { const insets = useSafeAreaInsets(); - const iconColor = useThemeColor("--color-icon"); - const dangerForegroundColor = useThemeColor("--color-danger-foreground"); const summaryResult = useAtomValue(clientCacheSummaryAtom); const clearResult = useAtomValue(clearClientCacheAtom); const clearCache = useAtomSet(clearClientCacheAtom); @@ -84,7 +81,7 @@ export function SettingsClientStorageRouteScreen() { @@ -119,7 +116,7 @@ export function SettingsClientStorageRouteScreen() { @@ -142,14 +139,16 @@ export function SettingsClientStorageRouteScreen() { {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} - {isClearing ? : null} + {isClearing ? ( + + ) : null} @@ -174,7 +173,6 @@ function CacheEnvironmentRow(props: { readonly first: boolean; readonly onClear: () => void; }) { - const iconColor = useThemeColor("--color-icon"); return ( diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 6b6d589fa4..793d265115 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -12,7 +12,7 @@ import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { applyShowcaseLocalEnvironmentDisplayUrls, @@ -44,8 +44,7 @@ export function SettingsEnvironmentsRouteScreen() { : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - const accentColor = useThemeColor("--color-icon-muted"); - const headerIconColor = useThemeColor("--color-icon"); + const headerIconColor = useUniwindTheme()["--color-icon"]; const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); @@ -148,7 +147,7 @@ export function SettingsEnvironmentsRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index a594240167..951168fefc 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { useThemeColor } from "../../lib/useThemeColor"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { mobileProjectGroupingModePatch, @@ -42,7 +41,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ export function SettingsProjectGroupingRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const checkmarkColor = useThemeColor("--color-icon"); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; @@ -92,7 +90,7 @@ export function SettingsProjectGroupingRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 9f2feb1c3b..9def444eea 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -33,7 +33,6 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; -import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -595,7 +594,6 @@ function LegacySettingsSection() { } function AppSettingsSection() { - const icon = useThemeColor("--color-icon"); const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); const hiddenUpdateTapCount = useRef(0); @@ -665,7 +663,7 @@ function AppSettingsSection() { diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 96a01c0511..79d67ebaa7 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,15 +1,23 @@ -import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react"; -import { useColorScheme } from "react-native"; +import { + createContext, + startTransition, + use, + useCallback, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Appearance, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { Uniwind } from "uniwind"; +import { ScopedTheme, Uniwind } from "uniwind"; import { resolveAppearance, resolveAppearancePreferences, - resolveTextScaleVariables, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; @@ -17,7 +25,6 @@ import type { Preferences } from "../../../persistence/mobile-preferences"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, - getMobileThemeVariables, normalizeMobileThemeMode, resolveMobileThemeIds, type MobileThemeAppearance, @@ -25,6 +32,11 @@ import { type MobileThemeIds, type MobileThemeMode, } from "../../../lib/mobileTheme"; +import { + createMobileThemeRuntimeOperations, + getMobileUniwindThemeName, + type MobileThemeRuntimeState, +} from "../../../lib/mobileThemeRuntime"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { @@ -51,30 +63,6 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); -/** - * Injects palette and text-scale variables into both adaptive stylesheets. - * Updating the active sheet last lets the visible app settle in one pass. - */ -function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) { - const textVariables = resolveTextScaleVariables(baseFontSize); - const currentTheme = Uniwind.currentTheme; - const activeAppearance = - currentTheme === "light" || currentTheme === "dark" ? currentTheme : null; - - for (const theme of ["light", "dark"] as const) { - const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables }; - if (theme !== activeAppearance) { - Uniwind.updateCSSVariables(theme, variables); - } - } - if (activeAppearance !== null) { - Uniwind.updateCSSVariables(activeAppearance, { - ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance), - ...textVariables, - }); - } -} - export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -88,54 +76,135 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ); const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; - const themeIds = useMemo( - () => resolveMobileThemeIds(storedPreferences ?? {}), - [storedPreferences], + const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {}); + const themeIds = useMemo( + () => ({ light: resolvedThemeIds.light, dark: resolvedThemeIds.dark }), + [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; - const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); + const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; + const appearance = useMemo( + () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }), + [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], + ); + // Preference patches are optimistic. Keep controls interactive while a save is + // in flight so rapid theme choices can supersede one another immediately. + const isReady = AsyncResult.isSuccess(preferencesResult); + const runtimeState = useMemo( + () => ({ + baseFontSize, + themeAppearance, + themeMode, + }), + [baseFontSize, themeAppearance, themeMode], + ); + const appliedRuntimeStateRef = useRef(null); + const selectedThemeIdsRef = useRef(themeIds); - useLayoutEffect(() => { - applyAppearanceVariables(preferences.baseFontSize, themeIds); - Uniwind.setTheme(themeMode); - cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences, themeIds, themeMode]); + const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { + const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); + for (const operation of operations) { + if (operation.kind === "update-text-variables") { + Uniwind.updateCSSVariables(operation.themeName, operation.variables); + continue; + } + if (operation.kind === "set-appearance-mode") { + Appearance.setColorScheme( + operation.themeMode === "system" ? "unspecified" : operation.appearance, + ); + } + } + appliedRuntimeStateRef.current = next; + }, []); + + const syncThemeRuntime = useCallback( + (next: MobileThemeRuntimeState) => applyThemeRuntime(next), + [applyThemeRuntime], + ); const updatePreferences = useCallback( (patch: Partial) => { + startTransition(() => savePreferences(patch)); + }, + [savePreferences], + ); + + const updateThemePreferences = useCallback( + (patch: Partial) => { + // Theme selection owns the visible root ScopedTheme value. Keep its + // optimistic atom update urgent so the first frame after a press is the + // complete new palette rather than a deferred transition render. savePreferences(patch); }, [savePreferences], ); + useLayoutEffect(() => { + selectedThemeIdsRef.current = themeIds; + syncThemeRuntime(runtimeState); + cacheTerminalFontSize(appearance.terminalFontSize); + }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { - updatePreferences( - createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value), + const patch = createMobileThemeSelectionPatch( + selectedThemeIdsRef.current, + themeAppearance, + appearance, + value, ); + selectedThemeIdsRef.current = resolveMobileThemeIds(patch); + updateThemePreferences(patch); }, - [themeAppearance, themeIds, updatePreferences], + [themeAppearance, updateThemePreferences], ); const setThemeIdForBothAppearances = useCallback( (value: MobileThemeId) => { - updatePreferences(createMobileThemePairPatch(value)); + const patch = createMobileThemePairPatch(value); + selectedThemeIdsRef.current = resolveMobileThemeIds(patch); + updateThemePreferences(patch); }, - [updatePreferences], + [updateThemePreferences], ); const setThemeMode = useCallback( (value: MobileThemeMode) => { - updatePreferences({ themeMode: value }); + const current = appliedRuntimeStateRef.current ?? runtimeState; + + // Clear a forced native appearance before publishing System. The + // resulting useColorScheme notification still sees the previous forced + // preference, so React batches the actual system palette into the one + // urgent preference commit below. + if (value === "system") { + Appearance.setColorScheme("unspecified"); + } + const nextAppearance = + value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value; + const next = { + ...current, + themeAppearance: nextAppearance, + themeMode: value, + }; + + updateThemePreferences({ themeMode: value }); + if (value === "system") { + appliedRuntimeStateRef.current = next; + } else { + syncThemeRuntime(next); + } }, - [updatePreferences], + [runtimeState, syncThemeRuntime, updateThemePreferences], ); const setBaseFontSize = useCallback( (value: number) => { + const current = appliedRuntimeStateRef.current ?? runtimeState; + syncThemeRuntime({ ...current, baseFontSize: value }); updatePreferences({ baseFontSize: value }); }, - [updatePreferences], + [runtimeState, syncThemeRuntime, updatePreferences], ); const setTerminalFontSize = useCallback( @@ -161,7 +230,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const value = useMemo( (): AppearancePreferencesContextValue => ({ - appearance: resolveAppearance(preferences), + appearance, themeId, themeIds, themeMode, @@ -176,7 +245,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN setCodeWordBreak, }), [ - preferences, + appearance, themeId, themeIds, themeMode, @@ -194,7 +263,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + {props.children} ); } diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index f9275eb373..55bd661a64 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -5,7 +5,7 @@ import { resolveMarkdownFontSizes, resolveMobileCodeSurface, } from "../../../../lib/appearancePreferences"; -import { useThemeColor } from "../../../../lib/useThemeColor"; +import { useUniwindTheme } from "../../../../lib/useUniwindTheme"; import { getMobileTerminalTheme } from "../../../terminal/terminalTheme"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; @@ -138,8 +138,9 @@ export function CodeAppearancePreview(props: { readonly wordBreak: boolean; }) { const surface = resolveMobileCodeSurface(props.fontSize); - const lineNumberColor = useThemeColor("--color-icon-subtle"); - const keywordColor = useThemeColor("--color-md-link"); + const theme = useUniwindTheme(); + const lineNumberColor = theme["--color-icon-subtle"]; + const keywordColor = theme["--color-md-link"]; const lineNumber = (line: CodePreviewLine, index: number) => ( ["name"]; @@ -36,10 +36,9 @@ export function FontSizeSliderRow(props: { readonly value: number; readonly onChange: (value: number) => void; }) { - const icon = useThemeColor("--color-icon"); - const iconMuted = String(useThemeColor("--color-icon-muted")); - const trackColor = String(useThemeColor("--color-secondary-border")); - const fillColor = String(useThemeColor("--color-primary")); + const theme = useUniwindTheme(); + const trackColor = theme["--color-secondary-border"]; + const fillColor = theme["--color-primary"]; const latest = useRef(props); latest.current = props; @@ -141,7 +140,7 @@ export function FontSizeSliderRow(props: { @@ -152,7 +151,7 @@ export function FontSizeSliderRow(props: { @@ -204,7 +203,7 @@ export function FontSizeSliderRow(props: { diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index ab2a993139..2257115828 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,22 +1,22 @@ import { memo, useId } from "react"; import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; +import { ScopedTheme } from "uniwind"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; import { SymbolView } from "../../../../components/AppSymbol"; import { AppText as Text } from "../../../../components/AppText"; import { - getMobileThemeVariables, getMobileThemePreviewColors, MOBILE_THEME_OPTIONS, type MobileThemeAppearance, type MobileThemeId, type MobileThemeIds, type MobileThemeMode, - type MobileThemeVariables, } from "../../../../lib/mobileTheme"; -import { useThemeColor } from "../../../../lib/useThemeColor"; +import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; +import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; const APPEARANCE_MODES: ReadonlyArray<{ @@ -28,6 +28,8 @@ const APPEARANCE_MODES: ReadonlyArray<{ { id: "dark", label: "Dark" }, ]; +const previewPercentage = (value: number) => `${value * 100}%`; + const PreviewOrb = memo(function PreviewOrb(props: { readonly appearance: MobileThemeAppearance; readonly compact?: boolean; @@ -46,9 +48,6 @@ const PreviewOrb = memo(function PreviewOrb(props: { Math.max(spec.action.center[0], 1 - spec.action.center[0]), Math.max(spec.action.center[1], 1 - spec.action.center[1]), ); - const position = (value: number) => `${value * 100}%`; - const radius = (value: number) => `${value * 100}%`; - return ( @@ -118,32 +117,26 @@ function ThemeCard(props: { readonly onSelect: (appearance: MobileThemeAppearance) => void; readonly themeId: MobileThemeId; }) { - const badgeBackground = useThemeColor("--color-card"); - const badgeIcon = useThemeColor("--color-icon"); - const choice = (appearance: MobileThemeAppearance, selected: boolean) => ( props.onSelect(appearance)} > {selected ? ( - + @@ -158,7 +151,10 @@ function ThemeCard(props: { accessibilityHint="Sets both light and dark appearances" accessibilityLabel={`${props.label} theme`} accessibilityRole="button" - accessibilityState={{ disabled: props.disabled }} + accessibilityState={{ + disabled: props.disabled, + selected: props.lightSelected && props.darkSelected, + }} className="absolute inset-0 rounded-[24px] active:bg-subtle" disabled={props.disabled} onPress={props.onSelectBoth} @@ -167,34 +163,26 @@ function ThemeCard(props: { {choice("light", props.lightSelected)} {choice("dark", props.darkSelected)} - - - {props.label} - - + + {props.label} + ); } -function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) { +function PreviewPane(props: { readonly compact?: boolean }) { return ( - + - - + + - - + + - - + + @@ -228,50 +204,31 @@ function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly co } function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { - const light = getMobileThemeVariables(props.themeIds.light, "light"); - const dark = getMobileThemeVariables(props.themeIds.dark, "dark"); - const currentBorder = useThemeColor("--color-border"); - const currentFrame = useThemeColor("--color-drawer"); - const currentIndicator = useThemeColor("--color-foreground-muted"); - const frameColor = - props.mode === "light" - ? light["--color-border"] - : props.mode === "dark" - ? dark["--color-border"] - : currentBorder; - const frameBackground = - props.mode === "light" - ? light["--color-drawer"] - : props.mode === "dark" - ? dark["--color-drawer"] - : currentFrame; - const indicatorColor = - props.mode === "light" - ? light["--color-foreground-muted"] - : props.mode === "dark" - ? dark["--color-foreground-muted"] - : currentIndicator; + if (props.mode === "system") { + return ( + + + + + + + + + + + + ); + } return ( - - - {props.mode === "system" ? ( - <> - - - - ) : ( - - )} + + + + + + - - + ); } @@ -288,11 +245,10 @@ function ModeCard(props: { accessibilityLabel={`${props.label} appearance`} accessibilityRole="radio" accessibilityState={{ checked: props.selected, disabled: props.disabled }} - className={ - props.selected - ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2" - : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2" - } + className={cn( + "min-w-0 flex-1 gap-2 rounded-[24px] p-2 active:scale-[0.97]", + props.selected ? "border-2 border-primary bg-subtle" : "border border-border bg-card", + )} disabled={props.disabled} onPress={props.onPress} > diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index 4224740c26..62f918a0e6 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,18 +1,12 @@ -import { useCSSVariable } from "uniwind"; +import { useMemo } from "react"; +import { + DEFAULT_BASE_FONT_SIZE, + normalizeBaseFontSize, + scaledTypographyLineHeight, +} from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; - -const TEXT_ROLE_VARIABLES = { - micro: "--text-3xs", - caption: "--text-2xs", - label: "--text-xs", - footnote: "--text-sm", - body: "--text-base", - headline: "--text-lg", - title: "--text-xl", - largeTitle: "--text-2xl", - display: "--text-3xl", -} as const satisfies Record; +import { useAppearancePreferences } from "./AppearancePreferencesProvider"; export interface ScaledTextRole { readonly fontSize: number; @@ -20,17 +14,21 @@ export interface ScaledTextRole { } /** - * Reads a typography role's current size from the Uniwind `--text-*` CSS - * variables (scaled at runtime with the base font size). Use for style-prop - * consumers that can't express their size as a `text-*` className. Reactive: - * re-renders when the appearance provider re-injects the variables. + * Mirrors the values injected into Uniwind for style-prop consumers that + * cannot use a `text-*` class. This deliberately does not subscribe to CSS + * variables, so palette-only setTheme calls remain native-only. */ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { - const variable = TEXT_ROLE_VARIABLES[role]; - const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]); - - return { - fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize, - lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight, - }; + const { appearance } = useAppearancePreferences(); + return useMemo(() => { + const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); + const typography = MOBILE_TYPOGRAPHY[role]; + return { + fontSize: Math.max( + 8, + Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), + ), + lineHeight: scaledTypographyLineHeight(typography, baseFontSize), + }; + }, [appearance.baseFontSize, role]); } diff --git a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx index aa5303b9a8..86e1008a14 100644 --- a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx @@ -6,12 +6,10 @@ import { WebView } from "react-native-webview"; import { AppText as Text } from "../../../components/AppText"; import { LoadingStrip } from "../../../components/LoadingStrip"; import { SymbolView } from "../../../components/AppSymbol"; -import { useThemeColor } from "../../../lib/useThemeColor"; import { isLegalDocumentUrl, LEGAL_URL } from "../lib/legal-document-url"; export function SettingsLegalDocumentCloseHeaderButton() { const navigation = useNavigation(); - const iconColor = useThemeColor("--color-icon"); return ( @@ -37,7 +35,6 @@ export function SettingsLegalDocumentExternalHeaderButton({ }: { readonly externalUrl?: string; }) { - const iconColor = useThemeColor("--color-icon"); const safeExternalUrl = isLegalDocumentUrl(externalUrl) ? externalUrl : LEGAL_URL; return ( @@ -51,7 +48,7 @@ export function SettingsLegalDocumentExternalHeaderButton({ @@ -69,7 +66,6 @@ export function SettingsLegalDocumentRouteScreen({ documentUrl, }: SettingsLegalDocumentRouteScreenProps) { const navigation = useNavigation>(); - const iconColor = useThemeColor("--color-icon"); const [reloadKey, setReloadKey] = useState(0); const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); @@ -94,7 +90,7 @@ export function SettingsLegalDocumentRouteScreen({ diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx index fcdcf7982f..f15f21b9ac 100644 --- a/apps/mobile/src/features/settings/components/SettingsRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -5,7 +5,6 @@ import { Pressable, View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; -import { useThemeColor } from "../../../lib/useThemeColor"; import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; type SymbolName = ComponentProps["name"]; @@ -20,8 +19,6 @@ export function SettingsRow(props: { readonly onPress?: () => void; }) { const navigation = useNavigation(); - const icon = useThemeColor("--color-icon"); - const chevron = useThemeColor("--color-chevron"); const content = ( - + {props.label} @@ -48,7 +51,7 @@ export function SettingsRow(props: { diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 2a63385a04..3abda36af6 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { ThemedSwitch } from "../../../components/ThemedSwitch"; -import { useThemeColor } from "../../../lib/useThemeColor"; type SymbolName = ComponentProps["name"]; @@ -16,8 +15,6 @@ export function SettingsSwitchRow(props: { readonly value: boolean; readonly onValueChange: (value: boolean) => void; }) { - const icon = useThemeColor("--color-icon"); - return ( - + {props.label} {props.subtitle ? ( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index b6466abc27..e3fa761d65 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -4,7 +4,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAttachedTerminalSession } from "../../state/use-terminal-session"; @@ -36,7 +35,6 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const nativeTerminalAvailable = hasNativeTerminalSurface(); - const iconColor = useThemeColor("--color-icon"); const terminalId = DEFAULT_TERMINAL_ID; const lastGridSizeRef = useRef({ cols: DEFAULT_TERMINAL_COLS, @@ -236,7 +234,12 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( className="h-8 w-8 items-center justify-center rounded-[8px] bg-subtle" onPress={props.onClose} > - + diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cbc47c99c2..7ecb9f6413 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -5,13 +5,12 @@ import { import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import { memo } from "react"; -import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; +import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { useThemeColor } from "../../lib/useThemeColor"; export type ComposerCommandItem = | { readonly id: string; @@ -51,7 +50,6 @@ interface ComposerCommandPopoverProps { } function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) { - const tintColor = useThemeColor("--color-glass-surface"); const baseStyle: ViewStyle = { borderRadius: 16, overflow: "hidden", @@ -59,7 +57,11 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st }; return ( - + {props.children} ); @@ -122,27 +124,23 @@ const CommandRow = memo(function CommandRow(props: { readonly isSlashSkill: boolean; }) { const iconName = itemIcon(props.item); - const iconColor = useThemeColor("--color-icon-subtle"); - const borderColor = useThemeColor("--color-border"); return ( ({ - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 14, - paddingVertical: 10, - gap: 10, - opacity: pressed ? 0.6 : 1, - borderBottomWidth: props.isLast ? 0 : 0.5, - borderBottomColor: borderColor, - })} + className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60" + style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }} > {props.item.type === "path" ? ( ) : iconName ? ( - + ) : null} {props.isSlashSkill && props.item.type === "skill" ? ( diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 8aeadc95cb..086f41a1bc 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -2,16 +2,16 @@ import * as Haptics from "expo-haptics"; import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; +import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); const OVERLAY_TOP_GAP = 8; @@ -67,14 +67,16 @@ export function GitActionProgressOverlay(props: { function OverlayContent(props: { readonly progress: GitActionProgress }) { const { progress } = props; - const iconColor = useThemeColor("--color-icon"); - const glassBorder = useThemeColor("--color-header-border"); - const glassTint = useThemeColor("--color-glass-tint"); - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; + const colors = useUniwindTheme(); + const glassSurface = colors["--color-glass-surface"]; + const foreground = colors["--color-foreground"]; + const shadowColor = colors["--color-primary-shadow"]; + const isDarkMode = useColorScheme() === "dark"; + const glassTint = themeColorWithAlpha(String(glassSurface), isDarkMode ? 0.48 : 0.38); + const glassBorder = themeColorWithAlpha(String(foreground), isDarkMode ? 0.18 : 0.12); const content = ( <> - + {progress.label ? ( @@ -90,7 +92,12 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.prUrl ? ( - + ) : null} ); @@ -100,12 +107,13 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { @@ -132,7 +143,7 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { const bgClass = progress.phase === "error" - ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + ? "border-adaptive-red-200-800 bg-adaptive-red-50-950-a80" : "bg-card border-border"; return ( @@ -145,13 +156,10 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { ); } -function OverlayIcon(props: { - readonly phase: GitActionProgress["phase"]; - readonly iconColor: ReturnType; -}) { +function OverlayIcon(props: { readonly phase: GitActionProgress["phase"] }) { switch (props.phase) { case "running": - return ; + return ; case "success": return ( diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 97bb2ab982..411598db08 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { useFontFamily } from "../../lib/useFontFamily"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; @@ -45,9 +45,6 @@ function SelectionRow(props: { readonly subtitle?: string; readonly title: string; }) { - const iconColor = useThemeColor("--color-icon-muted"); - const checkmarkColor = useThemeColor("--color-icon"); - return ( {props.icon ? ( - + ) : null} @@ -78,7 +80,7 @@ function SelectionRow(props: { @@ -191,8 +193,7 @@ export function NewTaskBranchPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const placeholderColor = useThemeColor("--color-placeholder"); - const foregroundColor = useThemeColor("--color-foreground"); + const foregroundColor = useUniwindTheme()["--color-foreground"]; const fontFamily = useFontFamily("regular"); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); @@ -419,7 +420,7 @@ export function NewTaskBranchPickerRouteScreen() { className="h-11 rounded-xl bg-card px-4 text-base text-foreground" onChangeText={flow.setBranchQuery} placeholder="Find a branch" - placeholderTextColor={placeholderColor} + placeholderTextColorClassName={"accent-placeholder"} style={{ color: foregroundColor, fontFamily }} value={flow.branchQuery} /> diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c9..5dbb1ed835 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -13,8 +13,7 @@ import { useKeyboardState, } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { themeColorWithAlpha } from "../../lib/mobileTheme"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; import { @@ -35,6 +34,8 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ComposerSurface } from "./ThreadComposer"; +import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { useComposerCommandMenu } from "./use-composer-command-menu"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, @@ -43,7 +44,6 @@ import { import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { clearComposerDraftContent, getComposerDraftSnapshot, @@ -71,17 +71,32 @@ function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null; }) { - const iconColor = useThemeColor("--color-icon-muted"); - if (props.workspaceMode === "local" && props.worktreePath === null) { - return ; + return ( + + ); } return ( - + - + ); @@ -109,7 +124,6 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -204,6 +218,19 @@ export function NewTaskDraftScreen(props: { const isIncomingShareTransferPending = Boolean( incomingShare && cancelledIncomingShareId !== props.incomingShareId, ); + const composerMenu = useComposerCommandMenu({ + draftMessage: flow.prompt, + environmentId: selectedProject?.environmentId ?? null, + projectCwd: + (flow.workspaceMode === "worktree" + ? selectedProject?.workspaceRoot + : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, + selectedProviderStatus: flow.selectedProviderStatus, + hasThread: false, + enabled: isComposerFocused && !isIncomingShareTransferPending, + onChangeDraftMessage: flow.setPrompt, + onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, + }); usePreventRemove( (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, () => undefined, @@ -291,13 +318,9 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const foregroundColor = useThemeColor("--color-foreground"); - const sheetColor = String(useThemeColor("--color-sheet")); - const projectUnderlineColor = useThemeColor("--color-foreground-muted"); + const foregroundColor = useUniwindTheme()["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const sheetFadeOpaque = sheetColor; - const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0); // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. @@ -704,10 +727,12 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + // Drop draft-local model/workspace selections with the content. The + // next task re-resolves project defaults before sticky app defaults. + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } navigation.getParent()?.goBack(); return; @@ -771,7 +796,10 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } navigation.dispatch( StackActions.replace("Thread", { @@ -797,7 +825,6 @@ export function NewTaskDraftScreen(props: { } const isAndroid = Platform.OS === "android"; - const isDarkMode = colorScheme === "dark"; const canStart = Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && @@ -816,8 +843,10 @@ export function NewTaskDraftScreen(props: { multiline scrollEnabled value={flow.prompt} - skills={flow.selectedProviderSkills} + skills={flow.selectedProviderStatus?.skills ?? []} + selection={composerMenu.selection} onChangeText={flow.setPrompt} + onSelectionChange={composerMenu.onSelectionChange} onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} @@ -874,11 +903,7 @@ export function NewTaskDraftScreen(props: { accessibilityRole="button" disabled={isIncomingShareTransferPending} onPress={chooseProject} - className="min-w-0 max-w-[250px] active:opacity-65" - style={{ - borderBottomColor: projectUnderlineColor, - borderBottomWidth: 1, - }} + className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65" > + {composerMenu.trigger && composerMenu.items.length > 0 ? ( + + + + ) : null} {workspaceControls} - + {projectScopes.length === 0 ? ( - {projectEmptyState.loading ? : null} + {projectEmptyState.loading ? ( + + ) : null} {projectEmptyState.title} @@ -308,7 +307,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index fb9cc72d25..0239cac1e0 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -28,15 +28,15 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} @@ -48,8 +48,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { option.decision === "accept" ? "bg-blue-500" : option.decision === "decline" - ? "bg-rose-100 dark:bg-rose-500/18" - : "bg-neutral-200 dark:bg-neutral-800" + ? "bg-adaptive-rose-100-500-a18" + : "bg-adaptive-neutral-200-800" }`} disabled={props.respondingApprovalId === props.approval.requestId} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -59,8 +59,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { option.decision === "accept" ? "font-t3-extrabold text-white" : option.decision === "decline" - ? "font-t3-bold text-rose-700 dark:text-rose-300" - : "font-t3-bold text-neutral-950 dark:text-neutral-50" + ? "font-t3-bold text-adaptive-rose-700-300" + : "font-t3-bold text-adaptive-neutral-950-50" }`} > {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4b5a93cd1f..5700d1b79e 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -18,7 +18,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; import { isPendingUserInputOptionSelected, type PendingUserInput, @@ -87,7 +86,6 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); export function PendingUserInputCard(props: PendingUserInputCardProps) { - const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; const cardCoverage = props.cardCoverage; @@ -163,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-1.5 pl-4 pr-1.5 dark:border-white/6 dark:bg-neutral-900" + className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} - + {props.onStopThread ? ( - + User input needed - + Fill in the pending answers - - + + - + {question.header} - + {question.question} @@ -268,8 +276,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", selected - ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" - : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", + ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" + : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", )} onPress={() => props.onSelectOption( @@ -284,14 +292,14 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className={cn( "font-t3-bold text-sm", selected - ? "text-sky-700 dark:text-sky-300" - : "text-neutral-700 dark:text-neutral-200", + ? "text-adaptive-sky-700-300" + : "text-adaptive-neutral-600-300", )} > {option.label} {description ? ( - + {description} ) : null} @@ -308,7 +316,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" /> ); @@ -317,7 +325,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {isReconnecting ? ( - + ) : ( )} @@ -272,9 +232,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const foregroundColor = useThemeColor("--color-foreground"); + const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; @@ -342,11 +300,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); - const toolbarSurface = String(useThemeColor("--color-card")); - const backdropSurface = String(useThemeColor("--color-screen")); - const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95); - const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0); - const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`; const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( @@ -356,212 +309,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - // ── Trigger detection ──────────────────────────────────── - const [composerSelection, setComposerSelection] = useState(() => ({ - start: props.draftMessage.length, - end: props.draftMessage.length, - })); - - const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { - setComposerSelection(selection); - }, []); - useEffect(() => { - const end = props.draftMessage.length; - setComposerSelection((selection) => { - const start = Math.min(selection.start, end); - const selectionEnd = Math.min(selection.end, end); - if (start === selection.start && selectionEnd === selection.end) { - return selection; - } - return { start, end: selectionEnd }; - }); - }, [props.draftMessage.length]); - - const composerTrigger = useMemo(() => { - if (composerSelection.start !== composerSelection.end) { - return null; - } - return detectComposerTrigger(props.draftMessage, composerSelection.end); - }, [composerSelection, props.draftMessage]); - const pathSearch = useComposerPathSearch({ + const composerMenu = useComposerCommandMenu({ + draftMessage: props.draftMessage, environmentId: props.environmentId, - cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, - query: composerTrigger?.kind === "path" ? composerTrigger.query : null, + projectCwd: props.projectCwd, + selectedProviderStatus, + hasThread: true, + onChangeDraftMessage: props.onChangeDraftMessage, + onUpdateInteractionMode: props.onUpdateInteractionMode, }); - - const composerMenuItems: ComposerCommandItem[] = useMemo(() => { - if (!composerTrigger) return []; - - if (composerTrigger.kind === "slash-command") { - const q = composerTrigger.query.toLowerCase(); - const hasProviderGoalCommand = (selectedProviderStatus?.slashCommands ?? []).some( - (command) => command.name.toLowerCase() === "goal", - ); - const goalCommandItems = - selectedProviderStatus?.driver === "codex" && !hasProviderGoalCommand - ? [ - { - id: "cmd:goal", - type: "slash-command" as const, - command: "goal", - label: "/goal", - description: "Set, view, pause, resume, or clear a task goal", - }, - ] - : []; - const allBuiltIn = [ - { - id: "cmd:model", - type: "slash-command" as const, - command: "model", - label: "/model", - description: "Switch model", - }, - { - id: "cmd:plan", - type: "slash-command" as const, - command: "plan", - label: "/plan", - description: "Switch to plan mode", - }, - { - id: "cmd:default", - type: "slash-command" as const, - command: "default", - label: "/default", - description: "Switch to default mode", - }, - ...goalCommandItems, - ]; - const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); - - const providerCommands: ComposerCommandItem[] = []; - for (const cmd of selectedProviderStatus?.slashCommands ?? []) { - if (!cmd.name.toLowerCase().includes(q)) continue; - providerCommands.push({ - id: `pcmd:${cmd.name}`, - type: "provider-slash-command" as const, - command: cmd, - label: `/${cmd.name}`, - description: cmd.description ?? "", - }); - } - - const skillItems = (selectedProviderStatus?.skills ?? []) - .filter((skill) => matchesSlashSkillQuery(skill, q)) - .map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: `skill:${skill.name}`, - description: skill.shortDescription ?? skill.description ?? "", - })); - - return [...builtIn, ...providerCommands, ...skillItems]; - } - - if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); - const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { - trimLeadingPattern: /^\$+/, - }); - - if (!normalizedQuery) { - return enabledSkills.slice(0, 20).map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } - - const ranked: Array<{ - item: (typeof enabledSkills)[number]; - score: number; - tieBreaker: string; - }> = []; - for (const skill of enabledSkills) { - const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); - const scores = [ - scoreQueryMatch({ - value: skill.name.toLowerCase(), - query: normalizedQuery, - exactBase: 0, - prefixBase: 2, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 100, - boundaryMarkers: ["-", "_", "/"], - }), - scoreQueryMatch({ - value: displayLabel, - query: normalizedQuery, - exactBase: 1, - prefixBase: 3, - boundaryBase: 5, - includesBase: 7, - fuzzyBase: 110, - }), - scoreQueryMatch({ - value: skill.shortDescription?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 20, - prefixBase: 22, - boundaryBase: 24, - includesBase: 26, - }), - scoreQueryMatch({ - value: skill.description?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 30, - prefixBase: 32, - boundaryBase: 34, - includesBase: 36, - }), - ].filter((s): s is number => s !== null); - - if (scores.length > 0) { - insertRankedSearchResult( - ranked, - { - item: skill, - score: Math.min(...scores), - tieBreaker: `${displayLabel}\u0000${skill.name}`, - }, - 20, - ); - } - } - - return ranked.map(({ item: skill }) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } - - if (composerTrigger.kind === "path") { - return pathSearch.entries.map((entry) => { - const parts = entry.path.split("/"); - return { - id: `path:${entry.path}`, - type: "path" as const, - path: entry.path, - kind: entry.kind, - label: parts[parts.length - 1] ?? entry.path, - description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", - }; - }); - } - - return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); - - // ── Handle command selection ────────────────────────────── - const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; + const { onSendMessage } = props; const handleSend = useCallback(async () => { const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); @@ -591,48 +348,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.id, props.selectedThread.title, ]); - const handleCommandSelect = useCallback( - (item: ComposerCommandItem) => { - if (!composerTrigger) return; - - if ( - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - onUpdateInteractionMode(item.command); - return; - } - - let replacement = ""; - if (item.type === "path") { - replacement = `${serializeComposerFileLink(item.path)} `; - } else if (item.type === "skill") { - replacement = `$${item.skill.name} `; - } else if (item.type === "slash-command") { - replacement = `/${item.command} `; - } else if (item.type === "provider-slash-command") { - replacement = `/${item.command.name} `; - } - - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - replacement, - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], - ); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( @@ -664,6 +379,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const settingsRouteSession = useMemo( () => ({ ownerId: settingsOwnerId, + environmentId: props.environmentId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), @@ -739,26 +455,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer silently drops experimental_backgroundImage on Android, which left this strip fully transparent and the feed text legible through the composer. */} - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerMenu.trigger && composerMenu.items.length > 0 ? ( ) : null} @@ -771,7 +482,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} void props.onNativePasteImages(uris)} placeholder={props.placeholder} onFocus={handleFocus} @@ -881,11 +591,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} {isExpanded ? ( - + void; }) { - const codeBackground = useThemeColor("--color-md-code-bg"); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -248,12 +247,9 @@ function ThreadMarkdownImageView(props: { > {props.uri === null || failed ? ( {failed ? ( @@ -271,13 +267,9 @@ function ThreadMarkdownImageView(props: { style={{ alignSelf: "flex-start" }} > ({ - background, - border, - mutedBackground, - text, - mutedText, - codeBackground, + background: theme["--color-card"], + border: theme["--color-border"], + mutedBackground: theme["--color-subtle"], + text: theme["--color-foreground"], + mutedText: theme["--color-foreground-muted"], + codeBackground: theme["--color-md-code-bg"], }), - [background, border, codeBackground, mutedBackground, mutedText, text], + [theme], ); } @@ -596,25 +583,26 @@ function useMarkdownStyles( [appearance.baseFontSize], ); const themeMode = themeAppearance; - const markdownBodyColor = String(useThemeColor("--color-md-body")); - const markdownStrongColor = String(useThemeColor("--color-md-strong")); - const markdownLinkColor = String(useThemeColor("--color-md-link")); - const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg")); - const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); - const markdownCodeBg = String(useThemeColor("--color-md-code-bg")); - const markdownCodeText = String(useThemeColor("--color-md-code-text")); - const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary")); - const markdownHrColor = String(useThemeColor("--color-md-hr")); - const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); - const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); - const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); - const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted")); - const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg")); - const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text")); - const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); - const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); - const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground")); - const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); + const theme = useUniwindTheme(); + const markdownBodyColor = theme["--color-md-body"]; + const markdownStrongColor = theme["--color-md-strong"]; + const markdownLinkColor = theme["--color-md-link"]; + const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; + const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; + const markdownCodeBg = theme["--color-md-code-bg"]; + const markdownCodeText = theme["--color-md-code-text"]; + const markdownInlineCodeText = theme["--color-foreground-secondary"]; + const markdownHrColor = theme["--color-md-hr"]; + const markdownUserBodyColor = theme["--color-user-bubble-foreground"]; + const markdownUserCodeBg = theme["--color-md-user-code-bg"]; + const markdownUserCodeText = theme["--color-md-user-code-text"]; + const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; + const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; + const markdownUserFenceText = theme["--color-md-user-fence-text"]; + const iconSubtleColor = theme["--color-icon-subtle"]; + const inlineSkillForeground = theme["--color-inline-skill-foreground"]; + const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; + const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); @@ -1014,7 +1002,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" > {entry.label} @@ -1022,7 +1010,7 @@ function renderFeedEntry( @@ -1046,7 +1034,9 @@ function renderFeedEntry( const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); - const attachments = message.attachments ?? []; + const attachments = (message.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); const hasReviewCommentContext = message.text.includes(" - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1161,7 +1151,7 @@ function renderFeedEntry( key={attachment.id} environmentId={props.environmentId} attachmentId={attachment.id} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressImage={props.onPressImage} /> ); @@ -1175,7 +1165,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} @@ -1211,11 +1201,11 @@ const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly st return ( - - - + + + - + Working for {durationLabel} @@ -1307,6 +1297,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { }) { const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); const NativeReviewDiffView = resolveNativeReviewDiffView(); const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); const parsedDiff = useMemo( @@ -1319,8 +1310,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme, themeId), - [appearanceScheme, themeId], + () => createNativeReviewDiffTheme(appearanceScheme, themeId, appTheme), + [appearanceScheme, appTheme, themeId], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( @@ -1582,8 +1573,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT : topContentInset; - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const userBubbleColor = useThemeColor("--color-user-bubble"); + const theme = useUniwindTheme(); + const iconSubtleColor = theme["--color-icon-subtle"]; + const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 0e74e27f87..fd4d0b9d0a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -27,7 +27,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; @@ -127,19 +126,11 @@ export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { - const backgroundColor = useThemeColor("--color-drawer"); - const borderColor = useThemeColor("--color-border"); - return ( @@ -731,10 +722,6 @@ function ThreadNavigationSidebarPane( ], ); - const backgroundColor = useThemeColor("--color-drawer"); - const borderColor = useThemeColor("--color-border"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const placeholderColor = useThemeColor("--color-placeholder"); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no @@ -1242,13 +1229,8 @@ function ThreadNavigationSidebarPane( return ( @@ -1286,14 +1268,11 @@ function ThreadNavigationSidebarPane( {/* Title slot doubles as the connection status surface: while an @@ -1318,7 +1297,12 @@ function ThreadNavigationSidebarPane( - + - - {props.option.label} - - {props.option.isDefault ? ( - - Default - - ) : null} - {props.option.isLegacy ? ( - - Legacy + + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} - ) : null} - + {props.option.subtitle ? ( + + {props.option.subtitle} + + ) : null} + {props.selected ? ( @@ -140,7 +157,6 @@ function ProviderHeader(props: { readonly modelCount: number; readonly onToggle: () => void; }) { - const iconSubtle = useThemeColor("--color-icon-subtle"); const content = ( <> @@ -156,7 +172,7 @@ function ProviderHeader(props: { @@ -192,7 +208,6 @@ function DisclosureRow(props: { readonly onPress: () => void; readonly isLast?: boolean; }) { - const iconSubtle = useThemeColor("--color-icon-subtle"); return ( ) : null} - + ); } @@ -222,7 +242,6 @@ function ChoiceRow(props: { readonly onPress: () => void; readonly isLast: boolean; }) { - const checkmarkColor = useThemeColor("--color-icon"); return ( @@ -281,6 +300,7 @@ type ThreadSettingsSubmenuPage = | { readonly kind: "runtime" }; type ThreadSettingsSessionProps = { + readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; @@ -332,6 +352,7 @@ export function useExistingThreadSettingsRoutePresentation() { } type ThreadSettingsSessionValue = { + readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -450,6 +471,7 @@ function ThreadSettingsSessionProvider( const value = useMemo( () => ({ + environmentId: props.environmentId, providerGroups: props.providerGroups, runtimeMode: props.runtimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -478,6 +500,7 @@ function ThreadSettingsSessionProvider( hasLegacyModels, isApplied, isDisplayed, + props.environmentId, pendingModel, pressModel, providerFilter, @@ -946,6 +969,23 @@ function ThreadSettingsModelsScreen() { const navigation = useNavigation>(); const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; + const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const refreshProviderCatalog = useMemo( + () => createProviderCatalogRefreshRunner(refreshProvidersCommand), + [refreshProvidersCommand], + ); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshProviders = useCallback(() => { + if (!session.environmentId || isRefreshingProviders) return; + setIsRefreshingProviders(true); + void refreshProviderCatalog(session.environmentId).then((result) => { + setIsRefreshingProviders(false); + const error = providerCatalogRefreshError(result); + if (error) Alert.alert("Could not refresh models", error); + }); + }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); const commitAndClose = useCallback(() => { session.commitPendingModel(); presentation.onClose(); @@ -993,6 +1033,12 @@ function ThreadSettingsModelsScreen() { {Platform.OS === "android" ? ( + ({ onClose: props.onClose, @@ -1217,6 +1271,7 @@ export function NewTaskThreadSettingsRouteScreen() { return ( flow.setSelectedModelKey(option.key, option.selection.options)} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index cdc7f1a64a..f263372bad 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 17e4de0ab6..5aefccb4ba 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -17,7 +17,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../../lib/useThemeColor"; +import { useUniwindTheme } from "../../../lib/useUniwindTheme"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; @@ -53,10 +53,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - - const iconColor = useThemeColor("--color-icon"); - const foregroundColor = String(useThemeColor("--color-foreground")); - const sheetColor = String(useThemeColor("--color-sheet")); + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; + const sheetColor = theme["--color-sheet"]; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -385,7 +384,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 61346fcef0..285c3414a9 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -1,7 +1,6 @@ import { SymbolView } from "../../../components/AppSymbol"; import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; -import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; @@ -14,12 +13,13 @@ export function SheetActionButton(props: { readonly tone?: "primary" | "secondary" | "danger"; readonly onPress: () => void; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryFg = useThemeColor("--color-secondary-foreground"); - const tone = props.tone ?? "secondary"; - const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; + const textColorClassName = + tone === "primary" + ? "accent-primary-foreground" + : tone === "danger" + ? "accent-danger-foreground" + : "accent-secondary-foreground"; return ( - + void; }) { - const iconColor = useThemeColor("--color-icon"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - return ( - + {props.title} @@ -89,7 +96,12 @@ export function SheetListRow(props: { {props.subtitle} ) : null} - + ); } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a..dc94adb419 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -7,7 +7,7 @@ import type { ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, - ServerProviderSkill, + ServerProvider, } from "@t3tools/contracts"; import { CommandId, @@ -33,6 +33,7 @@ import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -48,8 +49,10 @@ import { removeComposerDraftAttachment, replaceComposerDraftAttachments, setComposerDraftText, + setStickyComposerModelSelection, updateComposerDraftSettings, useComposerDraft, + useStickyComposerModelSelection, } from "../../state/use-composer-drafts"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; @@ -153,7 +156,7 @@ type NewTaskFlowContextValue = { readonly modelOptions: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; - readonly selectedProviderSkills: ReadonlyArray; + readonly selectedProviderStatus: ServerProvider | null; readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; @@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const stickyModelSelection = resolveDefaultableModelSelection( + selectedEnvironmentServerConfig, + storedStickyModelSelection, + ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection, + draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, ), - [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], + [ + selectedEnvironmentServerConfig, + draftModelSelection, + projectDefaultModelSelection, + stickyModelSelection, + ], ); - const selectedModel = - draftModelSelection ?? - projectDefaultModelSelection ?? - modelOptions.find((option) => option.isDefault)?.selection ?? - modelOptions[0]?.selection ?? - null; + // An unsent draft keeps its explicit pick. Fresh drafts resolve the project + // default before the last manual app-wide selection and provider default. + const selectedModel = resolveNewTaskModelSelection({ + draftSelection: draftModelSelection, + projectDefaultSelection: projectDefaultModelSelection, + stickySelection: stickyModelSelection, + modelOptions, + }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; @@ -444,11 +459,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( + const selectedProviderStatus = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? [], + ) ?? null, [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( @@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!option) { return; } - updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: options ? { ...option.selection, options } : option.selection, - }); + const selection = options ? { ...option.selection, options } : option.selection; + updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); + setStickyComposerModelSelection(selection); }, [modelOptions, selectedProjectDraftKey], ); @@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); + setStickyComposerModelSelection(nextSelection); }, [selectedModel, selectedProjectDraftKey], ); @@ -1028,7 +1044,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions, selectedModel, selectedModelOption, - selectedProviderSkills, + selectedProviderStatus, providerGroups, filteredBranches, reset, @@ -1090,7 +1106,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModelKey, selectedModelOption, selectedProjectDraftKey, - selectedProviderSkills, + selectedProviderStatus, setSelectedModelOptions, selectedProject, selectedProjectKey, diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts new file mode 100644 index 0000000000..565a540744 --- /dev/null +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts @@ -0,0 +1,46 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createProviderCatalogRefreshRunner, + providerCatalogRefreshError, +} from "./provider-catalog-refresh"; + +describe("mobile provider catalog refresh", () => { + it("calls server discovery for the selected environment and deduplicates pending taps", async () => { + let resolveRefresh: ((value: "refreshed") => void) | undefined; + const refreshProviders = vi.fn( + () => + new Promise<"refreshed">((resolve) => { + resolveRefresh = resolve; + }), + ); + const refresh = createProviderCatalogRefreshRunner(refreshProviders); + const environmentId = EnvironmentId.make("environment-mobile"); + + const first = refresh(environmentId); + const second = refresh(environmentId); + + expect(second).toBe(first); + expect(refreshProviders).toHaveBeenCalledOnce(); + expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); + + resolveRefresh?.("refreshed"); + await expect(first).resolves.toBe("refreshed"); + }); + + it("reports a discovery error and allows retry after the failed command settles", async () => { + const failure = AsyncResult.failure(Cause.fail(new Error("discovery failed"))); + const success = AsyncResult.success("refreshed"); + let callCount = 0; + const refreshProviders = vi.fn(async () => (callCount++ === 0 ? failure : success)); + const refresh = createProviderCatalogRefreshRunner(refreshProviders); + const environmentId = EnvironmentId.make("environment-mobile"); + + expect(providerCatalogRefreshError(await refresh(environmentId))).toBe("discovery failed"); + expect(providerCatalogRefreshError(await refresh(environmentId))).toBeNull(); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.ts new file mode 100644 index 0000000000..3e79a5b1c7 --- /dev/null +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.ts @@ -0,0 +1,34 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +type RefreshProvidersTarget = { + readonly environmentId: EnvironmentId; + readonly input: Record; +}; + +/** Deduplicates taps while the server refresh command is still running. */ +export function createProviderCatalogRefreshRunner( + refreshProviders: (target: RefreshProvidersTarget) => Promise, +) { + let pending: Promise | null = null; + + return (environmentId: EnvironmentId): Promise => { + if (pending) return pending; + pending = refreshProviders({ environmentId, input: {} }).finally(() => { + pending = null; + }); + return pending; + }; +} + +export function providerCatalogRefreshError( + result: AtomCommandResult, +): string | null { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null; + const error = squashAtomCommandFailure(result); + return error instanceof Error ? error.message : "Provider discovery failed."; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 1895ef0d45..61ab1fb41f 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,8 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; - export type SidebarFilterButtonIcon = | "line.3.horizontal.decrease.circle" | "line.3.horizontal.decrease.circle.fill"; @@ -11,8 +9,6 @@ export function SidebarFilterButton(props: { readonly accessibilityLabel: string; readonly icon: SidebarFilterButtonIcon; }) { - const iconColor = useThemeColor("--color-foreground"); - return ( - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index 9ce77f8991..52fb8c6999 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,8 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable, View } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; - export interface SidebarHeaderActionsProps { readonly onOpenSettings: () => void; } @@ -12,8 +10,6 @@ function FallbackHeaderButton(props: { readonly icon: "gearshape" | "square.and.pencil"; readonly onPress: () => void; }) { - const iconColor = useThemeColor("--color-foreground"); - return ( - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index d5e09b07e1..e9e204e477 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -10,7 +10,6 @@ import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -53,8 +52,7 @@ const SidebarStack = createNativeStackNavigator(); * navigation hooks used for header configuration inside the pane. */ export function SidebarNavigationShell(props: { readonly children: ReactNode }) { - const { themeAppearance } = useAppearancePreferences(); - const navigationTheme = useMobileNavigationTheme(themeAppearance); + const navigationTheme = useMobileNavigationTheme(); return ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 78e6e43c07..df10e585aa 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -19,7 +19,7 @@ import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; @@ -87,7 +87,6 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: readonly newThreadTarget?: EnvironmentProject | null; readonly onNewThread?: (project: EnvironmentProject) => void; }) { - const iconMutedColor = useThemeColor("--color-icon-muted"); const { groupKey, onGroupAction, onNewThread } = props; const newThreadTarget = props.newThreadTarget ?? null; const compact = props.variant === "compact"; @@ -171,7 +170,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: @@ -190,7 +189,6 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; }) { - const iconSubtleColor = useThemeColor("--color-icon-subtle"); const showsMore = props.hiddenCount > 0; const compact = props.variant === "compact"; const { groupKey, onGroupAction } = props; @@ -221,7 +219,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: @@ -275,10 +273,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; - const separatorColor = useThemeColor("--color-separator"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); + const theme = useUniwindTheme(); + const separatorColor = theme["--color-separator"]; + const pressedBackgroundColor = theme["--color-subtle"]; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const timestamp = relativeTime(pendingTask.message.createdAt); @@ -294,8 +291,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - - Pending + + Pending ); @@ -305,7 +302,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { @@ -446,13 +443,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); - const separatorColor = useThemeColor("--color-separator"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const screenColor = useThemeColor("--color-screen"); - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); + const theme = useUniwindTheme(); + const separatorColor = theme["--color-separator"]; + const screenColor = theme["--color-screen"]; + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; + const selectedBackgroundColor = theme["--color-user-bubble"]; + const selectedForegroundColor = theme["--color-user-bubble-foreground"]; const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; @@ -600,7 +597,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 1467792800..51b1ed7afc 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -16,7 +16,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; @@ -52,10 +52,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, - input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, - working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, - failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, + approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, + input: { label: "Input", className: "text-adaptive-indigo-600-300" }, + working: { label: "Working", className: "text-adaptive-sky-600-400" }, + failed: { label: "Failed", className: "text-adaptive-red-700-300" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -93,7 +93,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid readonly label: string; readonly pane?: "screen" | "sidebar"; }) { - const borderColor = useThemeColor("--color-border"); return ( {props.label} - + ); }); @@ -134,10 +133,10 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + void; readonly pane?: "screen" | "sidebar"; }) { - const mutedColor = useThemeColor("--color-foreground-muted"); return ( @@ -213,8 +211,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); + const theme = useUniwindTheme(); + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; const sidebarPane = props.pane === "sidebar"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; @@ -418,11 +417,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onChangeRequestState?.(threadKey, changeRequest); }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); - const screenColor = useThemeColor("--color-screen"); - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const pinTintColor = useThemeColor("--color-foreground-muted"); + const theme = useUniwindTheme(); + const screenColor = theme["--color-screen"]; + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; + const selectedBackgroundColor = theme["--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; @@ -699,7 +698,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.projectTitle ?? props.project?.title ?? ""} {pinnedRow ? ( - + ) : null} @@ -905,7 +907,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-blue-600 dark:text-blue-400" + ? "text-adaptive-blue-600-400" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index da80ca0766..48aaf80249 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,8 +65,8 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-blue-500 dark:text-blue-400" - : "text-emerald-600 dark:text-emerald-400", + ? "text-adaptive-blue-500-400" + : "text-adaptive-emerald-600-400", )} > {isUser ? "You:" : "Agent:"}{" "} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 2e8fee9857..5c6e25f437 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -12,7 +12,7 @@ function modelOption( return { key: `codex:${model}`, label: model, - subtitle: "Codex", + subtitle: "", providerKey: "codex", providerLabel: "Codex", providerDriver: "codex", @@ -48,6 +48,21 @@ describe("thread settings sheet state", () => { ).toBe(true); }); + it("matches the upstream provider's display name", () => { + const model = { + ...modelOption("opencode/claude-fable-5"), + label: "Claude Fable 5", + subtitle: "OpenCode Zen", + }; + + expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: " ZEN " })).toBe( + true, + ); + expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: "copilot" })).toBe( + false, + ); + }); + it("clears staging when the applied model is pressed", () => { expect( pendingModelAfterPress({ diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a5adacb8d1..192d82f92c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -7,7 +7,6 @@ import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; -import { useThemeColor } from "../../lib/useThemeColor"; import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { @@ -128,7 +127,6 @@ export function ThreadWorkLog(props: { readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; }) { - const pressedBackground = useThemeColor("--color-subtle"); const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), @@ -178,10 +176,7 @@ export function ThreadWorkLog(props: { } }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackground : "transparent", - })} - className="rounded-md px-0.5 py-0" + className="rounded-md px-0.5 py-0 active:bg-subtle" > @@ -198,7 +193,7 @@ export function ThreadWorkLog(props: { {row.summary} @@ -210,7 +205,7 @@ export function ThreadWorkLog(props: { {props.copiedRowId === row.id ? ( - + Copied ) : null} @@ -239,7 +234,7 @@ export function ThreadWorkLog(props: { : { ios: "minus", android: "remove" } } size={11} - tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor} + tintColor={props.iconSubtleColor} type="monochrome" /> ) : null} @@ -249,7 +244,7 @@ export function ThreadWorkLog(props: { {fullDetail ? ( - + void; }) { - const pressedBackground = useThemeColor("--color-subtle"); const noun = props.onlyToolActivities ? props.hiddenCount === 1 ? "tool call" @@ -305,10 +299,7 @@ export function ThreadWorkGroupToggle(props: { void Haptics.selectionAsync(); props.onToggle(); }} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackground : "transparent", - })} - className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0" + className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" > void; + readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; +}) { + const [selection, setSelection] = useState(() => ({ + start: draftMessage.length, + end: draftMessage.length, + })); + const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => { + setSelection(nextSelection); + }, []); + useEffect(() => { + const end = draftMessage.length; + setSelection((current) => { + const start = Math.min(current.start, end); + const selectionEnd = Math.min(current.end, end); + if (start === current.start && selectionEnd === current.end) { + return current; + } + return { start, end: selectionEnd }; + }); + }, [draftMessage.length]); + + const trigger = useMemo(() => { + if (!enabled || selection.start !== selection.end) { + return null; + } + return detectComposerTrigger(draftMessage, selection.end); + }, [draftMessage, enabled, selection]); + const pathSearch = useComposerPathSearch({ + environmentId, + cwd: trigger?.kind === "path" ? projectCwd : null, + query: trigger?.kind === "path" ? trigger.query : null, + }); + + const items = useMemo(() => { + if (!trigger) return []; + + if (trigger.kind === "slash-command") { + const q = trigger.query.toLowerCase(); + const allBuiltIn = [ + { + id: "cmd:model", + type: "slash-command" as const, + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "cmd:plan", + type: "slash-command" as const, + command: "plan", + label: "/plan", + description: "Switch to plan mode", + }, + { + id: "cmd:default", + type: "slash-command" as const, + command: "default", + label: "/default", + description: "Switch to default mode", + }, + ...(hasThread && selectedProviderStatus?.driver === "codex" + ? [ + { + id: "cmd:goal", + type: "slash-command" as const, + command: "goal", + label: "/goal", + description: "Set, view, pause, resume, or clear a task goal", + }, + ] + : []), + ]; + const builtIn = allBuiltIn.filter( + (item) => + item.command.includes(q) && + (item.command === "model" || onUpdateInteractionMode !== undefined), + ); + + const providerCommands: ComposerCommandItem[] = []; + for (const command of selectedProviderStatus?.slashCommands ?? []) { + if (!command.name.toLowerCase().includes(q)) continue; + // Codex feedback uploads an existing thread's session and logs. + if ( + !hasThread && + selectedProviderStatus?.driver === "codex" && + command.name === "feedback" + ) { + continue; + } + providerCommands.push({ + id: `pcmd:${command.name}`, + type: "provider-slash-command", + command, + label: `/${command.name}`, + description: command.description ?? "", + }); + } + + const skillItems = (selectedProviderStatus?.skills ?? []) + .filter((skill) => matchesSlashSkillQuery(skill, q)) + .map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: `skill:${skill.name}`, + description: skill.shortDescription ?? skill.description ?? "", + })); + + return [...builtIn, ...providerCommands, ...skillItems]; + } + + if (trigger.kind === "skill") { + const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled); + const normalizedQuery = normalizeSearchQuery(trigger.query, { + trimLeadingPattern: /^\$+/, + }); + + if (!normalizedQuery) { + return enabledSkills.slice(0, 20).map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + const ranked: Array<{ + item: (typeof enabledSkills)[number]; + score: number; + tieBreaker: string; + }> = []; + for (const skill of enabledSkills) { + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); + const scores = [ + scoreQueryMatch({ + value: skill.name.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: displayLabel, + query: normalizedQuery, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: skill.shortDescription?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: skill.description?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + ].filter((score): score is number => score !== null); + + if (scores.length > 0) { + insertRankedSearchResult( + ranked, + { + item: skill, + score: Math.min(...scores), + tieBreaker: `${displayLabel}\u0000${skill.name}`, + }, + 20, + ); + } + } + + return ranked.map(({ item: skill }) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + if (trigger.kind === "path") { + return pathSearch.entries.map((entry) => { + const parts = entry.path.split("/"); + return { + id: `path:${entry.path}`, + type: "path" as const, + path: entry.path, + kind: entry.kind, + label: parts[parts.length - 1] ?? entry.path, + description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", + }; + }); + } + + return []; + }, [hasThread, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, trigger]); + + const onSelect = useCallback( + (item: ComposerCommandItem) => { + if (!trigger) return; + + if ( + item.type === "slash-command" && + (item.command === "plan" || item.command === "default") + ) { + const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + onUpdateInteractionMode?.(item.command); + return; + } + + let replacement = ""; + if (item.type === "path") { + replacement = `${serializeComposerFileLink(item.path)} `; + } else if (item.type === "skill") { + replacement = `$${item.skill.name} `; + } else if (item.type === "slash-command") { + replacement = `/${item.command} `; + } else if (item.type === "provider-slash-command") { + replacement = `/${item.command.name} `; + } + + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + replacement, + ); + setSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + }, + [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger], + ); + + return { + selection, + onSelectionChange, + trigger, + items, + isLoading: pathSearch.isPending, + onSelect, + }; +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f228..2576ac21fb 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + grok: "Grok Build", }; /** - * Claude's brand orange holds in both themes; Codex is neutral and must flip - * with the theme or its bars vanish against the matching background. + * Claude's brand orange holds in both themes; Codex and Grok are neutrals and + * must flip with the theme or their bars vanish against the matching background. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: scheme === "dark" ? "#a1a1aa" : "#52525b", }; } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 992beed3ab..d706d00fbe 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM return { label: "T3 Code Mobile", - deviceType: "mobile", + deviceType: + Device.deviceType === Device.DeviceType.TABLET + ? "tablet" + : Device.deviceType === Device.DeviceType.PHONE + ? "mobile" + : "unknown", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), ...(deviceModel ? { deviceModel } : {}), diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index f474c7e6ea..6487e572c8 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); const mobileDevice = vi.hoisted(() => ({ + deviceType: 1, + DeviceType: { + UNKNOWN: 0, + PHONE: 1, + TABLET: 2, + DESKTOP: 3, + TV: 4, + }, osVersion: "18.4.1", modelName: "iPhone 15 Pro", })); @@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice); describe("mobile remote connection records", () => { afterEach(() => { mobilePlatform.OS = "ios"; + mobileDevice.deviceType = mobileDevice.DeviceType.PHONE; mobileDevice.osVersion = "18.4.1"; mobileDevice.modelName = "iPhone 15 Pro"; }); @@ -56,6 +65,17 @@ describe("mobile remote connection records", () => { }); }); + it("identifies native tablets separately from phones", () => { + mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; + mobileDevice.modelName = "iPad Pro 13-inch"; + + expect(authClientMetadata()).toMatchObject({ + deviceType: "tablet", + os: "iOS", + deviceModel: "iPad Pro 13-inch", + }); + }); + it("includes the mobile app version when the client provides it", () => { expect(authClientMetadata("1.2.3")).toMatchObject({ surface: "mobile", diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts deleted file mode 100644 index 48a84b3f98..0000000000 --- a/apps/mobile/src/lib/mobileBranding.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveMobileStageLabel } from "./mobileBranding"; - -describe("resolveMobileStageLabel", () => { - it.each([ - ["development", "Dev"], - ["preview", "Nightly"], - ["production", "Alpha"], - [undefined, "Alpha"], - ])("maps %s builds to %s", (appVariant, expected) => { - expect(resolveMobileStageLabel(appVariant)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts new file mode 100644 index 0000000000..a702bb9afb --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -0,0 +1,20 @@ +import * as NodeFS from "node:fs"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; + +export function readDefaultMobileThemeVariables( + appearance: MobileThemeAppearance, +): MobileThemeVariables { + const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); + const variant = new RegExp(`@variant ${appearance} \\{([\\s\\S]*?)\\n \\}`, "u").exec( + stylesheet, + )?.[1]; + if (variant === undefined) throw new Error(`Missing default ${appearance} theme in global.css.`); + + return Object.fromEntries( + Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ + name, + value.trim(), + ]), + ) as MobileThemeVariables; +} diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index d5744952bb..a3c6712aba 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import * as NodeFS from "node:fs"; - import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; +import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { createMobileThemePairPatch, @@ -11,7 +9,6 @@ import { DEFAULT_MOBILE_THEME_ID, getMobileThemePreviewColors, getMobileThemeVariables, - MOBILE_THEME_IDS, normalizeMobileThemeId, normalizeMobileThemeMode, resolveMobileThemeIds, @@ -52,13 +49,12 @@ function compositeOver(overlay: string, background: string): string { describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { - const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); - const stylesheetVariables = new Set( - Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable), + const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( + Object.keys(generatedVariables).sort(), ); - - expect(Array.from(stylesheetVariables).sort()).toEqual( - Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(), + expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual( + Object.keys(generatedVariables).sort(), ); }); @@ -71,17 +67,11 @@ describe("mobile themes", () => { }); it("preserves the existing mobile palette as the default", () => { - expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe( - "#f2f2f7", + expect(readDefaultMobileThemeVariables("light")["--color-screen"]).toBe("#f2f2f7"); + expect(readDefaultMobileThemeVariables("dark")["--color-screen"]).toBe("#0a0a0a"); + expect(readDefaultMobileThemeVariables("light")["--color-user-bubble-skill-foreground"]).toBe( + "#f0abfc", ); - expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe( - "#0a0a0a", - ); - expect( - getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[ - "--color-user-bubble-skill-foreground" - ], - ).toBe("#f0abfc"); }); it("applies palette overrides on top of the selected built-in theme", () => { @@ -172,17 +162,11 @@ describe("mobile themes", () => { expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); - expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual( - Object.keys(variables).sort(), - ); - expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual( - Object.keys(variables).sort(), - ); }); it("keeps every built-in shadow and backdrop black-based in dark mode", () => { - for (const theme of BUILT_IN_THEMES) { - const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark"); + for (const themeId of BUILT_IN_THEME_IDS) { + const variables = getMobileThemeVariables(themeId, "dark"); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); @@ -190,7 +174,7 @@ describe("mobile themes", () => { }); it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { - for (const themeId of MOBILE_THEME_IDS) { + for (const themeId of BUILT_IN_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { const variables = getMobileThemeVariables(themeId, appearance); expect( diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 36de7f979d..2303451128 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -3,6 +3,7 @@ import { getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, + type BuiltInThemeId, type MobileThemeId as SharedMobileThemeId, type ThemeAppearance, type ThemeColors, @@ -11,7 +12,6 @@ import { STANDARD_THEME_PREVIEW_COLORS, type ThemePreviewColors, } from "@t3tools/shared/themePreview"; -import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; @@ -28,7 +28,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; -type MobileThemeVariable = `--color-${string}`; +export type MobileThemeVariable = `--color-${string}`; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { @@ -282,18 +282,18 @@ export function createMobileThemeVariables( }; } +export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( + createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), +) as ReadonlyArray; + export function getMobileThemeVariables( - themeId: MobileThemeId, + themeId: BuiltInThemeId, appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { - const baseVariables = (() => { - if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance]; - const theme = - BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; - const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; - return createMobileThemeVariables(colors, appearance); - })(); + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; + const baseVariables = createMobileThemeVariables(colors, appearance); // The complete base record guarantees that optional overrides cannot leave a token undefined. return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables; diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts new file mode 100644 index 0000000000..ad678d1f88 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createMobileThemeRuntimeOperations, + getMobileUniwindThemeName, + type MobileThemeRuntimeState, +} from "./mobileThemeRuntime"; + +const initialState: MobileThemeRuntimeState = { + baseFontSize: 16, + themeAppearance: "light", + themeMode: "system", +}; + +describe("mobileThemeRuntime", () => { + it("keeps the default palette on Uniwind's built-in appearance themes", () => { + expect(getMobileUniwindThemeName("t3-code", "light")).toBe("light"); + expect(getMobileUniwindThemeName("t3-code", "dark")).toBe("dark"); + }); + + it("maps custom palettes and appearances to registered themes", () => { + expect(getMobileUniwindThemeName("t3-chat", "dark")).toBe("t3-chat-dark"); + }); + + it("hydrates text variables and clears the native appearance override", () => { + const operations = createMobileThemeRuntimeOperations(null, initialState); + const variableOperations = operations.filter( + (operation) => operation.kind === "update-text-variables", + ); + + expect(variableOperations).toHaveLength(12); + expect(variableOperations.at(-1)?.themeName).toBe("iris-dark"); + expect(operations.at(-1)).toEqual({ + kind: "set-appearance-mode", + appearance: "light", + themeMode: "system", + }); + }); + + it("lets system appearance changes flow through the root ScopedTheme only", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + themeAppearance: "dark", + }); + + expect(operations).toEqual([]); + }); + + it("updates native appearance once when the selected mode changes", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + themeAppearance: "dark", + themeMode: "dark", + }); + + expect(operations).toEqual([ + { + kind: "set-appearance-mode", + appearance: "dark", + themeMode: "dark", + }, + ]); + }); + + it("updates text variables for every theme without switching palettes", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + baseFontSize: 18, + }); + + expect(operations).toHaveLength(12); + expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true); + expect(operations.at(-1)).toMatchObject({ + kind: "update-text-variables", + themeName: "iris-dark", + }); + }); + + it("does no native work when persistence echoes an already-applied state", () => { + expect(createMobileThemeRuntimeOperations(initialState, initialState)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts new file mode 100644 index 0000000000..0c30de6bd4 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeRuntime.ts @@ -0,0 +1,75 @@ +import { resolveTextScaleVariables } from "./appearancePreferences"; +import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; +import { + DEFAULT_MOBILE_THEME_ID, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeMode, +} from "./mobileTheme"; + +export type MobileUniwindThemeName = + | MobileThemeAppearance + | `${BuiltInThemeId}-${MobileThemeAppearance}`; + +export interface MobileThemeRuntimeState { + readonly baseFontSize: number; + readonly themeAppearance: MobileThemeAppearance; + readonly themeMode: MobileThemeMode; +} + +export type MobileThemeRuntimeOperation = + | { + readonly kind: "update-text-variables"; + readonly themeName: "light" | "dark" | MobileUniwindThemeName; + readonly variables: Readonly>; + } + | { + readonly kind: "set-appearance-mode"; + readonly appearance: MobileThemeAppearance; + readonly themeMode: MobileThemeMode; + }; + +const UNIWIND_THEME_NAMES: ReadonlyArray<"light" | "dark" | MobileUniwindThemeName> = [ + "light", + "dark", + ...BUILT_IN_THEME_IDS.flatMap((themeId) => [ + `${themeId}-light` as const, + `${themeId}-dark` as const, + ]), +]; + +export function getMobileUniwindThemeName( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, +): MobileUniwindThemeName { + return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; +} + +/** + * Plans imperative runtime work separately from theme selection. Palette + * changes are handled by one root ScopedTheme render; only typography and the + * native appearance override need imperative Uniwind/React Native updates. + */ +export function createMobileThemeRuntimeOperations( + previous: MobileThemeRuntimeState | null, + next: MobileThemeRuntimeState, +): ReadonlyArray { + const operations: MobileThemeRuntimeOperation[] = []; + + if (previous === null || previous.baseFontSize !== next.baseFontSize) { + const variables = resolveTextScaleVariables(next.baseFontSize); + for (const themeName of UNIWIND_THEME_NAMES) { + operations.push({ kind: "update-text-variables", themeName, variables }); + } + } + + if (previous === null || previous.themeMode !== next.themeMode) { + operations.push({ + kind: "set-appearance-mode", + appearance: next.themeAppearance, + themeMode: next.themeMode, + }); + } + + return operations; +} diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts new file mode 100644 index 0000000000..78c002b2d5 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeVariables.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; +import { getMobileThemeVariables } from "./mobileTheme"; +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; + +describe("mobile theme runtime variables", () => { + it("derives the standard runtime palette from global.css", () => { + expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual( + readDefaultMobileThemeVariables("light"), + ); + expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual( + readDefaultMobileThemeVariables("dark"), + ); + }); + + it("uses the same shared palette source as generated custom themes", () => { + expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual( + getMobileThemeVariables("ocean", "light"), + ); + expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual( + getMobileThemeVariables("iris", "dark"), + ); + }); +}); diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts new file mode 100644 index 0000000000..79a479b4fe --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -0,0 +1,27 @@ +import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json"; + +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeVariables, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeVariables, +} from "./mobileTheme"; + +const defaults = defaultThemeVariables as Readonly< + Record +>; + +/** + * Complete palette for native and third-party APIs that cannot consume a + * Uniwind className. The standard palette is generated from global.css; custom + * palettes share the same source that generates their registered CSS themes. + */ +export function getMobileThemeRuntimeVariables( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, +): MobileThemeVariables { + return themeId === DEFAULT_MOBILE_THEME_ID + ? defaults[appearance] + : getMobileThemeVariables(themeId, appearance); +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe03..aafc49e360 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, + type ModelOption, } from "./modelOptions"; describe("mobile model options", () => { @@ -44,13 +46,62 @@ describe("mobile model options", () => { providerKey: "codex", providerLabel: "Codex", models: [ - { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, + { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", subtitle: "", isLegacy: false }, { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, ], }, ]); }); + it("distinguishes same-name OpenCode models without changing their routing", () => { + const sources = [ + { id: "anthropic", label: "Anthropic" }, + { id: "github-copilot", label: "GitHub Copilot" }, + { id: "opencode", label: "OpenCode Zen" }, + ]; + const config = { + providers: [ + { + instanceId: "opencode_work", + driver: "opencode", + displayName: "OpenCode Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: sources.map((source) => ({ + slug: `${source.id}/claude-fable-5`, + name: "Claude Fable 5", + subProvider: source.label, + isCustom: false, + capabilities: null, + })), + }, + ], + } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("opencode_work"), + model: "github-copilot/claude-fable-5", + }; + + const options = buildModelOptions(config, selection); + + expect(options).toMatchObject( + sources.map((source) => ({ + key: `opencode_work:${source.id}/claude-fable-5`, + label: "Claude Fable 5", + subtitle: source.label, + providerLabel: "OpenCode Work", + selection: { + instanceId: "opencode_work", + model: `${source.id}/claude-fable-5`, + }, + })), + ); + expect(groupByProvider(options)).toEqual([ + { providerKey: "opencode_work", providerLabel: "OpenCode Work", models: options }, + ]); + }); + it("normalizes a legacy fallback selection against current capabilities", () => { const config = { providers: [ @@ -171,4 +222,30 @@ describe("mobile model options", () => { // Offline: nothing to validate against, selection passes through. expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); }); + + it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; + const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; + const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; + const providerDefault = { + selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + isDefault: true, + } as ModelOption; + const resolve = ( + draftSelection: ModelSelection | null, + projectDefaultSelection: ModelSelection | null, + stickySelection: ModelSelection | null, + ) => + resolveNewTaskModelSelection({ + draftSelection, + projectDefaultSelection, + stickySelection, + modelOptions: [providerDefault], + }); + + expect(resolve(draft, project, sticky)).toBe(draft); + expect(resolve(null, project, sticky)).toBe(project); + expect(resolve(null, null, sticky)).toBe(sticky); + expect(resolve(null, null, null)).toBe(providerDefault.selection); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198..26ffd68555 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection( return model?.isLegacy === true ? null : usable; } +export function resolveNewTaskModelSelection(input: { + readonly draftSelection: ModelSelection | null; + readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; + readonly modelOptions: ReadonlyArray; +}): ModelSelection | null { + return ( + input.draftSelection ?? + input.projectDefaultSelection ?? + input.stickySelection ?? + input.modelOptions.find((option) => option.isDefault)?.selection ?? + input.modelOptions[0]?.selection ?? + null + ); +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -121,7 +137,7 @@ export function buildModelOptions( options.set(key, { key, label: model.name, - subtitle: providerLabel, + subtitle: model.subProvider ?? "", providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, @@ -152,7 +168,7 @@ export function buildModelOptions( options.set(key, { key, label: fallbackModelSelection.model, - subtitle: providerLabel, + subtitle: "", providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index fe022c1191..a1c7960a57 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,12 @@ describe("mobile connection storage", () => { }); }); + it("drops the removed theme transition preference", async () => { + mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); + + await expect(loadPreferences()).resolves.toEqual({}); + }); + it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index a775988297..e1ed10f26f 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -325,6 +325,38 @@ describe("buildThreadFeed", () => { ]); }); + it("drops runtime warnings with no displayable content", () => { + const thread = makeThread({ + id: ThreadId.make("thread-noise"), + projectId: ProjectId.make("project-1"), + title: "Warning noise thread", + activities: [ + makeActivity({ + id: EventId.make("activity-noise"), + kind: "runtime.warning", + summary: "Claude system message 'background_tasks_changed' (no displayable text content)", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: TurnId.make("turn-1"), + }), + makeActivity({ + id: EventId.make("activity-signal"), + kind: "runtime.warning", + summary: "Reconnecting... 2/5", + createdAt: "2026-04-01T00:00:03.000Z", + turnId: TurnId.make("turn-1"), + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "activity-signal" }], + }, + ]); + }); + it("collapses matching tool lifecycle rows like desktop", () => { const thread = makeThread({ id: ThreadId.make("thread-2"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9e0cb64ae8..6dd8230307 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -338,6 +338,7 @@ function deriveWorkLogEntries( if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; + if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); @@ -345,6 +346,17 @@ function deriveWorkLogEntries( return collapseDerivedWorkLogEntries(entries); } +/** Adapters forward unknown wire-only SDK messages (background_tasks_changed, + * commands_changed, ...) as runtime warnings. The suffix comes from + * describeUnknownSdkMessage in the Claude adapter; a row with no displayable + * text carries nothing a user can act on, so it does not render. */ +function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "runtime.warning" && + activity.summary.endsWith("(no displayable text content)") + ); +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; diff --git a/apps/mobile/src/lib/useFontFamily.ts b/apps/mobile/src/lib/useFontFamily.ts index 09805ae115..4f845753be 100644 --- a/apps/mobile/src/lib/useFontFamily.ts +++ b/apps/mobile/src/lib/useFontFamily.ts @@ -1,15 +1,13 @@ -import { useCSSVariable } from "uniwind"; - -const FONT_FAMILY_VARIABLES = { - regular: "--font-sans", - medium: "--font-medium", - bold: "--font-bold", +const FONT_FAMILIES = { + regular: "DMSans-Regular", + medium: "DMSans-Medium", + bold: "DMSans-Bold", } as const; /** * Resolves a font family for APIs that require a style object or native prop. * Prefer Uniwind font classes when the target component accepts `className`. */ -export function useFontFamily(weight: keyof typeof FONT_FAMILY_VARIABLES): string { - return useCSSVariable(FONT_FAMILY_VARIABLES[weight]) as string; +export function useFontFamily(weight: keyof typeof FONT_FAMILIES): string { + return FONT_FAMILIES[weight]; } diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts index 6711f72c74..7b7a1fdf3c 100644 --- a/apps/mobile/src/lib/useMobileNavigationTheme.ts +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -1,22 +1,31 @@ import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; import { useMemo } from "react"; -import type { MobileThemeAppearance } from "./mobileTheme"; -import { useThemeColor } from "./useThemeColor"; - -export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme { - const primary = String(useThemeColor("--color-primary")); - const background = String(useThemeColor("--color-screen")); - const card = String(useThemeColor("--color-sheet-solid")); - const text = String(useThemeColor("--color-foreground")); - const border = String(useThemeColor("--color-header-border")); - const notification = String(useThemeColor("--color-danger-foreground")); +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useUniwindTheme } from "./useUniwindTheme"; +/** + * React Navigation requires a JS theme object. Derive it from the same palette + * source as Uniwind instead of subscribing the app root to CSS variables. The + * preferences provider applies the registered Uniwind theme first, then + * publishes this matching navigation palette through React. + */ +export function useMobileNavigationTheme(): Theme { + const { themeAppearance: appearance } = useAppearancePreferences(); + const variables = useUniwindTheme(); return useMemo(() => { const base = appearance === "dark" ? DarkTheme : DefaultTheme; return { ...base, - colors: { ...base.colors, primary, background, card, text, border, notification }, + colors: { + ...base.colors, + primary: variables["--color-primary"], + background: variables["--color-screen"], + card: variables["--color-sheet-solid"], + text: variables["--color-foreground"], + border: variables["--color-header-border"], + notification: variables["--color-danger-foreground"], + }, }; - }, [appearance, background, border, card, notification, primary, text]); + }, [appearance, variables]); } diff --git a/apps/mobile/src/lib/useThemeColor.ts b/apps/mobile/src/lib/useThemeColor.ts deleted file mode 100644 index 38dbf6c9b0..0000000000 --- a/apps/mobile/src/lib/useThemeColor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ColorValue } from "react-native"; -import { useCSSVariable } from "uniwind"; - -/** - * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use - * in React Native style props (backgroundColor, tintColor, etc.). - * - * Usage: `const color = useThemeColor("--color-icon");` - */ -export function useThemeColor(variable: `--color-${string}`): ColorValue { - return useCSSVariable(variable) as string as ColorValue; -} diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts new file mode 100644 index 0000000000..06c50c859a --- /dev/null +++ b/apps/mobile/src/lib/useUniwindTheme.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { MobileThemeVariables } from "./mobileTheme"; +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; + +/** + * Complete JS palette for native and third-party APIs that cannot consume a + * Uniwind className (React Navigation, native editors, Markdown, SVG gradients, + * Reanimated worklets). Ordinary React Native rendering must use className. + * + * This bridge follows the same single React theme commit as the root + * ScopedTheme instead of subscribing every consumer to CSS-variable updates. + */ +export function useUniwindTheme(): MobileThemeVariables { + const { themeAppearance, themeId } = useAppearancePreferences(); + return useMemo( + () => getMobileThemeRuntimeVariables(themeId, themeAppearance), + [themeAppearance, themeId], + ); +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 32094109b1..cb818245d6 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -14,7 +14,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; -import { useThemeColor } from "../lib/useThemeColor"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { @@ -110,15 +110,7 @@ export function ComposerEditor({ const nativeEventSnapshotsRef = useRef([]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); - const textColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); - const chipBackground = useThemeColor("--color-subtle"); - const chipBorder = useThemeColor("--color-border"); - const chipText = useThemeColor("--color-foreground"); - const skillBackground = useThemeColor("--color-inline-skill-background"); - const skillBorder = useThemeColor("--color-inline-skill-border"); - const skillText = useThemeColor("--color-inline-skill-foreground"); - const fileTint = useThemeColor("--color-icon-muted"); + const theme = useUniwindTheme(); const fontFamily = useFontFamily("regular"); useImperativeHandle( @@ -219,15 +211,15 @@ export function ComposerEditor({ [], ); const themeJson = JSON.stringify({ - text: String(textColor), - placeholder: String(placeholderColor), - chipBackground: String(chipBackground), - chipBorder: String(chipBorder), - chipText: String(chipText), - skillBackground: String(skillBackground), - skillBorder: String(skillBorder), - skillText: String(skillText), - fileTint: String(fileTint), + text: theme["--color-foreground"], + placeholder: theme["--color-placeholder"], + chipBackground: theme["--color-subtle"], + chipBorder: theme["--color-border"], + chipText: theme["--color-foreground"], + skillBackground: theme["--color-inline-skill-background"], + skillBorder: theme["--color-inline-skill-border"], + skillText: theme["--color-inline-skill-foreground"], + fileTint: theme["--color-icon-muted"], }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; return ( diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index ff177abf16..5f5201f3bf 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -18,7 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; -import { useThemeColor } from "../lib/useThemeColor"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; import { acknowledgeComposerNativeEvent, assumeComposerControlledState, @@ -111,15 +111,7 @@ export function ComposerEditor({ const nativeEventSnapshotsRef = useRef([]); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); - const textColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); - const chipBackground = useThemeColor("--color-subtle"); - const chipBorder = useThemeColor("--color-border"); - const chipText = useThemeColor("--color-foreground"); - const skillBackground = useThemeColor("--color-inline-skill-background"); - const skillBorder = useThemeColor("--color-inline-skill-border"); - const skillText = useThemeColor("--color-inline-skill-foreground"); - const fileTint = useThemeColor("--color-icon-muted"); + const theme = useUniwindTheme(); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -220,15 +212,15 @@ export function ComposerEditor({ [], ); const themeJson = JSON.stringify({ - text: String(textColor), - placeholder: String(placeholderColor), - chipBackground: String(chipBackground), - chipBorder: String(chipBorder), - chipText: String(chipText), - skillBackground: String(skillBackground), - skillBorder: String(skillBorder), - skillText: String(skillText), - fileTint: String(fileTint), + text: theme["--color-foreground"], + placeholder: theme["--color-placeholder"], + chipBackground: theme["--color-subtle"], + chipBorder: theme["--color-border"], + chipText: theme["--color-foreground"], + skillBackground: theme["--color-inline-skill-background"], + skillBorder: theme["--color-inline-skill-border"], + skillText: theme["--color-inline-skill-foreground"], + fileTint: theme["--color-icon-muted"], }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index e082d3892a..30bc0cceea 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,7 +2,6 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; @@ -21,8 +20,6 @@ export function ComposerEditor({ }: ComposerEditorProps) { const inputRef = useRef(null); const bodyText = useScaledTextRole("body"); - const foregroundColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); @@ -45,12 +42,12 @@ export function ComposerEditor({ selection={selection} onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} multiline={props.multiline ?? true} - placeholderTextColor={placeholderColor} + placeholderTextColorClassName={"accent-placeholder"} + className="text-foreground" style={[ { flex: 1, minHeight: 0, - color: foregroundColor, fontFamily, ...bodyText, paddingVertical: contentInsetVertical, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 76d57d5579..ab8e5a20f0 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -17,9 +17,9 @@ export interface ThreadPrPresentation { } const PR_STATE_TEXT_CLASS: Record = { - open: "text-emerald-600 dark:text-emerald-400", - merged: "text-violet-600 dark:text-violet-400", - closed: "text-zinc-500 dark:text-zinc-400", + open: "text-adaptive-emerald-600-400", + merged: "text-adaptive-violet-600-400", + closed: "text-adaptive-zinc-500-400", }; export function presentThreadPr( diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fe..5713090543 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => { let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); + let nextWriteBarrier: Promise | null = null; + let onWrite: (() => void) | null = null; + const writes: string[] = []; return { blockRead() { @@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => { setWriteError(error: Error | null) { writeError = error; }, + setNextWriteBarrier(barrier: Promise | null) { + nextWriteBarrier = barrier; + }, + setOnWrite(callback: (() => void) | null) { + onWrite = callback; + }, + getWrites(): ReadonlyArray { + return writes; + }, + resetWrites() { + writes.length = 0; + }, Directory: class { create() {} }, @@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => { if (writeError) { throw writeError; } + if (nextWriteBarrier) { + const barrier = nextWriteBarrier; + nextWriteBarrier = null; + return barrier.then(() => { + document = value; + writes.push(value); + onWrite?.(); + }); + } document = value; + writes.push(value); + onWrite?.(); } }, }; @@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({ import { appAtomRegistry } from "./atom-registry"; import { clearComposerDraftContentState, + clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, + decodePersistedComposerState, decodePersistedComposerDrafts, + ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, removeComposerDraftsForEnvironment, + resetComposerDraftsLoadState, restoreComposerDraftSnapshotState, setComposerDraftText, + setStickyComposerModelSelection, + stickyComposerModelSelectionAtom, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = { }; afterEach(() => { + vi.useRealTimers(); + resetComposerDraftsLoadState(); + composerDraftFileMocks.setDocument(""); + composerDraftFileMocks.setWriteError(null); + composerDraftFileMocks.setNextWriteBarrier(null); + composerDraftFileMocks.setOnWrite(null); + composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(stickyComposerModelSelectionAtom, null); }); describe("mobile composer drafts", () => { @@ -154,6 +194,195 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it("keeps share-import receipts on otherwise contentless new-task drafts", () => { + const receiptDraft: ComposerDraft = { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }; + // The stale-model strip must not touch receipt-bearing drafts, and the + // empty filter must keep them — or the same share would re-import after + // restart. + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + ...receiptDraft, + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + }, + }, + }, + }).drafts, + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }, + }); + + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { "new-task:environment-1:project-1": receiptDraft }, + }).drafts, + ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); + }); + + it("hydrates the global sticky model selection", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }).stickyModelSelection, + ).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("waits for hydration before persisting the latest composer state", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // The read is blocked, hydration is pending. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + await vi.advanceTimersByTimeAsync(200); + + // Write should still be deferred — hydration has not resolved. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + // Let the loadPromise settle and chain into the deferred persist. + await vi.runAllTimersAsync(); + + expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + "new-task:environment-1:project-1": { + text: "New prompt", + attachments: [], + }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + + it("flush waits for pending hydration instead of clobbering disk", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // An edit lands before hydration finishes; its debounced write is gated + // behind the blocked read. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + + const flush = flushComposerDrafts(); + await vi.advanceTimersByTimeAsync(200); + // The flush must not have written the pre-hydration snapshot over disk. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + await flush; + + const written = JSON.parse(composerDraftFileMocks.getDocument()); + expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT); + expect(written.drafts["new-task:environment-1:project-1"]).toEqual({ + text: "New prompt", + attachments: [], + }); + expect(written.stickyModelSelection).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("serializes environment cleanup after an older queued write", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} })); + composerDraftFileMocks.resetWrites(); + let releaseFirstWrite!: () => void; + const firstWriteBarrier = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier); + let writeCount = 0; + const bothWritesCommitted = new Promise((resolve) => { + composerDraftFileMocks.setOnWrite(() => { + writeCount += 1; + if (writeCount === 2) { + resolve(); + } + }); + }); + + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": DRAFT, + "environment-2:thread-2": { text: "keep", attachments: [] }, + }); + setStickyComposerModelSelection({ + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }); + await vi.advanceTimersByTimeAsync(200); + + const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1")); + await Promise.resolve(); + // Cleanup write is queued behind the still-blocked debounced write. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + releaseFirstWrite(); + await clear; + await bothWritesCommitted; + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ + schemaVersion: 1, + drafts: { + "environment-2:thread-2": { text: "keep", attachments: [] }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + it("clears sent content without clearing the selected model or workspace", () => { const draftKey = "environment-1:thread-1"; const draft: ComposerDraft = { @@ -182,7 +411,7 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { + it("drops draft-local model and workspace selections after sending a new task", () => { const draftKey = "new-task:environment-1:project-1"; const draft: ComposerDraft = { text: "send this", @@ -201,15 +430,10 @@ describe("mobile composer drafts", () => { expect( clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { + clearModelSelection: true, clearWorkspaceSelection: true, }), - ).toEqual({ - [draftKey]: { - modelSelection: draft.modelSelection, - text: "", - attachments: [], - }, - }); + ).toEqual({}); }); it("reads the latest selector state synchronously for send", () => { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596..1cc3ef4e93 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -86,6 +86,7 @@ const ComposerDraftSchema = Schema.Struct({ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), + stickyModelSelection: Schema.optional(ModelSelectionSchema), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( @@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:sticky-composer-model-selection"), +); + let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; const persistenceQueue = new SerializedAsyncQueue(); +/** Resets module-level state between test runs. */ +export function resetComposerDraftsLoadState(): void { + loadPromise = null; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } -export function decodePersistedComposerDrafts(value: unknown): Record { +export function decodePersistedComposerState(value: unknown): { + readonly drafts: Record; + readonly stickyModelSelection: ModelSelection | null; +} { const parsed = decodePersistedComposerDraftsDocument(value); - return Object.fromEntries( - Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); + return { + drafts: Object.fromEntries( + Object.entries(parsed.drafts) + .map( + ([key, draft]) => + [ + key, + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + key.startsWith("new-task:") && + draft.modelSelection && + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ? { ...draft, modelSelection: undefined } + : draft, + ] as const, + ) + // importedShareIds are share-import receipts: a contentless draft + // carrying one is not empty, or the same native share would be + // re-imported after restart. + .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), + ), + stickyModelSelection: parsed.stickyModelSelection ?? null, + }; +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + return decodePersistedComposerState(value).drafts; } async function getComposerDraftsFile() { @@ -150,17 +196,20 @@ async function getComposerDraftsFile() { return new File(directory, COMPOSER_DRAFTS_FILE); } -async function loadPersistedComposerDrafts(): Promise> { +async function loadPersistedComposerState(): Promise<{ + readonly drafts: Record; + readonly stickyModelSelection: ModelSelection | null; +}> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { - return {}; + return { drafts: {}, stickyModelSelection: null }; } operation = "read"; const raw = await file.text(); operation = "decode"; - return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); + return decodePersistedComposerState(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise): Promise { +async function writePersistedComposerState( + drafts: Record, + stickyModelSelection: ModelSelection | null, +): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); @@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { - try { - await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); - } catch (error) { - console.warn("[composer-drafts] failed to persist drafts", error); - // Draft persistence is best-effort; in-memory drafts still keep working. - } -} - /** * Lands any debounced or in-flight draft write before the JS runtime is torn * down (app update restart), so the freshest draft state survives it. A write * failure propagates so the caller can decide whether the restart may proceed. */ export async function flushComposerDrafts(): Promise { + // Never land a pre-hydration snapshot: persisted state must merge into the + // atoms first, or this write would clobber disk with partial data. + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } // An edit during an awaited write schedules another debounced write, so // keep landing snapshots until no debounce is pending after a queue drain. do { @@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise { clearTimeout(persistTimer); persistTimer = null; await persistenceQueue.run(() => - writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), + writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ), ); } + // Draining also waits for an already-fired debounce whose write is still + // gated behind its own hydration await inside the queue. await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null); } -function schedulePersistComposerDrafts(drafts: Record): void { +function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; - void savePersistedComposerDrafts(drafts); + ensureComposerDraftsLoaded(); + // The write enters the serialization queue before waiting on hydration, + // so flushComposerDrafts' queue drain cannot resolve ahead of it. + void persistenceQueue.run(async () => { + if (loadPromise !== null) { + await loadPromise; + } + try { + await writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ); + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); + // Draft persistence is best-effort; in-memory drafts still keep working. + } + }); }, PERSIST_DEBOUNCE_MS); } @@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } - loadPromise = loadPersistedComposerDrafts() - .then((persistedDrafts) => { - if (Object.keys(persistedDrafts).length === 0) { - return; + loadPromise = loadPersistedComposerState() + .then((persisted) => { + if (Object.keys(persisted.drafts).length > 0) { + const current = appAtomRegistry.get(composerDraftsAtom); + appAtomRegistry.set(composerDraftsAtom, { + ...persisted.drafts, + ...current, + }); + } + if ( + persisted.stickyModelSelection !== null && + appAtomRegistry.get(stickyComposerModelSelectionAtom) === null + ) { + appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } - const current = appAtomRegistry.get(composerDraftsAtom); - appAtomRegistry.set(composerDraftsAtom, { - ...persistedDrafts, - ...current, - }); }) .catch((cause) => { console.warn( @@ -277,7 +353,12 @@ function updateComposerDrafts( return; } appAtomRegistry.set(composerDraftsAtom, next); - schedulePersistComposerDrafts(next); + schedulePersistComposerState(); +} + +export function setStickyComposerModelSelection(modelSelection: ModelSelection): void { + appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection); + schedulePersistComposerState(); } export function setComposerDraftText(draftKey: string, value: string): void { @@ -394,15 +475,24 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { + importedShareIds: _importedShareIds, + modelSelection, + workspaceSelection, + ...retained + } = existing; const draft = { ...retained, + ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -571,7 +661,9 @@ export async function mergeComposerDraftContent( if (next !== current) { appAtomRegistry.set(composerDraftsAtom, next); } - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); return { skippedAttachmentCount }; } @@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot( snapshot, ); appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); } export function clearComposerDraftContent( draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); } @@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); } export function useComposerDraft(draftKey: string | null): ComposerDraft { @@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft { }, []); return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT; } + +export function useStickyComposerModelSelection(): ModelSelection | null { + const selection = useAtomValue(stickyComposerModelSelectionAtom); + useEffect(() => { + ensureComposerDraftsLoaded(); + }, []); + return selection; +} diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index a355dddcd4..1313f202fa 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -17,7 +17,7 @@ describe("presentThreadPr", () => { expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ label: "3774", accessibilityLabel: "#3774 pull request merged", - textClassName: "text-violet-600 dark:text-violet-400", + textClassName: "text-adaptive-violet-600-400", }); }); diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts index cc099419a9..22856ab57f 100644 --- a/apps/mobile/uniwind-types.d.ts +++ b/apps/mobile/uniwind-types.d.ts @@ -3,7 +3,7 @@ declare module 'uniwind' { export interface UniwindConfig { - themes: readonly ['light', 'dark'] + themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] } } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 081e8c2ee8..5779cc5918 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -383,7 +383,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { start: () => Effect.void, - drain: Effect.void, + drainThrough: () => Effect.void, }), ), Layer.provideMerge( diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 1add889c6b..4c483c34a8 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -20,7 +20,15 @@ const emitThoughtChunks = process.env.T3_ACP_EMIT_THOUGHT_CHUNKS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; +const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1"; +const emitXAiAskUserQuestionThenHang = + process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; +const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; +const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; +const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; @@ -40,12 +48,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const initialGrokReasoningEffort = + process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", }; +const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1"; +const permissionRequestCount = Math.max( + 1, + Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, +); const sessionId = "mock-session-1"; let currentModeId = "ask"; @@ -280,7 +295,13 @@ function modeState(): AcpSchema.SessionModeState { } const grokAcpModels: ReadonlyArray = [ - { modelId: "grok-build", name: "Grok Build" }, + { + modelId: "grok-build", + name: "Grok Build", + ...(initialGrokReasoningEffort + ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } + : {}), + }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; @@ -523,6 +544,68 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitXAiRateLimitThenHang) { + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1", + stopReason: "rate_limit", + agentResult: null, + }); + return yield* Effect.never; + } + + if (emitContentThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "partial before stall" }, + }, + }); + return yield* Effect.never; + } + + if (emitPlanThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Wait for more ACP progress", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + return yield* Effect.never; + } + + if (emitActiveToolThenHang) { + const toolCallId = "tool-call-long-running-1"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Long-running tool", + kind: "execute", + status: "pending", + rawInput: { command: ["long-running-tool"] }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + return yield* Effect.never; + } + if (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, @@ -685,37 +768,58 @@ const program = Effect.gen(function* () { }, }); - const permission = yield* agent.client.requestPermission({ - sessionId: requestedSessionId, - toolCall: { - toolCallId, - title: "`cat server/package.json`", - kind: "execute", - status: "pending", - content: [ - { - type: "content", - content: { - type: "text", - text: "Not in allowlist: cat server/package.json", + const permissionOptions: Array = [ + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + ...(omitAllowAlways + ? [] + : [ + { + optionId: permissionOptionIds.allowAlways, + name: "Allow always", + kind: "allow_always" as const, }, + ]), + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, + ]; + + let cancelled = cancelledSessions.delete(requestedSessionId); + for (let index = 0; index < permissionRequestCount; index++) { + const command = + index > 0 + ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json") + : "cat server/package.json"; + const permission = yield* agent.client.requestPermission({ + sessionId: requestedSessionId, + toolCall: { + toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, + title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, + kind: "execute", + status: "pending", + rawInput: { + variant: "Bash", + command, + description: index === 0 ? "Read package metadata" : "Read it again", }, - ], - }, - options: [ - { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, - { - optionId: permissionOptionIds.allowAlways, - name: "Allow always", - kind: "allow_always", + content: [ + { + type: "content", + content: { + type: "text", + text: `Not in allowlist: ${command}`, + }, + }, + ], }, - { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, - ], - }); - - const cancelled = - cancelledSessions.delete(requestedSessionId) || - permission.outcome.outcome === "cancelled"; + options: permissionOptions, + }); + cancelled = + cancelled || + cancelledSessions.delete(requestedSessionId) || + permission.outcome.outcome === "cancelled"; + if (cancelled) { + break; + } + } yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -802,7 +906,7 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } - if (emitXAiAskUserQuestion) { + if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) { const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { method: "x.ai/ask_user_question", params: { @@ -836,6 +940,84 @@ const program = Effect.gen(function* () { throw new Error("Expected accepted _x.ai/ask_user_question response answers."); } + if (emitXAiAskUserQuestionThenHang) { + return yield* Effect.never; + } + + return { stopReason: "end_turn" }; + } + + if (emitXAiPlanMdWrite) { + // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it. + const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok"; + const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`; + const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n"; + // enter_plan_mode first so the adapter arms planModeActive. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "enter-plan-mode-1", + title: "enter_plan_mode", + kind: "other", + status: "completed", + rawInput: { variant: "EnterPlanMode" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "plan-md-write-1", + title: "write", + kind: "edit", + status: "pending", + rawInput: { file_path: planPath, content: planBody }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "plan-md-write-1", + kind: "edit", + status: "completed", + title: `Write \`${planPath}\``, + rawInput: { file_path: planPath, content: planBody }, + content: [ + { + type: "diff", + path: planPath, + oldText: "", + newText: planBody, + }, + ], + }, + }); + return { stopReason: "end_turn" }; + } + + if (emitXAiExitPlanMode) { + const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + method: "x.ai/exit_plan_mode", + params: { + sessionId: requestedSessionId, + toolCallId: "exit-plan-mode-tool-call-1", + planContent: "# Exit plan\n\n- Step one\n- Step two\n", + }, + }); + if (typeof result !== "object" || result === null || !("outcome" in result)) { + throw new Error("Expected _x.ai/exit_plan_mode response outcome."); + } + if ( + result.outcome !== "abandoned" && + result.outcome !== "approved" && + result.outcome !== "request_changes" + ) { + throw new Error( + `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`, + ); + } return { stopReason: "end_turn" }; } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts deleted file mode 100644 index 91754290db..0000000000 --- a/apps/server/scripts/cliErrors.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; - -describe("server CLI errors", () => { - it("preserves failed command context without changing its message", () => { - const error = new ServerCliCommandExitError({ - command: "vp", - args: ["pm", "publish"], - cwd: "/repo", - exitCode: 17, - }); - - assert.equal(error._tag, "ServerCliCommandExitError"); - assert.equal(error.command, "vp"); - assert.deepEqual(error.args, ["pm", "publish"]); - assert.equal(error.cwd, "/repo"); - assert.equal(error.exitCode, 17); - assert.equal(error.message, "Command exited with non-zero exit code (17)"); - }); - - it("preserves a representative missing asset path", () => { - const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); - - assert.equal(error.assetPath, "/repo/server.mjs"); - assert.equal( - error.message, - "Missing build asset: /repo/server.mjs. Run the build subcommand first.", - ); - }); -}); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 232a41e5a9..dff11524ed 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -37,7 +37,7 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([ version: Schema.Literal(1), kind: Schema.Literal("attachment"), attachmentId: Schema.String, + /** Decided at mint time. Absent tokens (from before this field) serve + inline, which is only ever the image case. */ + download: Schema.optionalKey(Schema.Boolean), + /** Display name and mime the caller supplied at mint time; drive the + download filename and Content-Type. */ + fileName: Schema.optionalKey(Schema.String), + mimeType: Schema.optionalKey(Schema.String), expiresAt: Schema.Number, }), Schema.Struct({ @@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -286,13 +299,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } + // Generic files carry their extension inside the attachment id (that + // shape resolves the on-disk path); images do not. Only generic files + // download, images render inline in chat. + const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null; claims = { version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, + ...(isGenericFile ? { download: true } : {}), + ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), + ...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}), expiresAt, }; - fileName = path.basename(attachmentPath); + fileName = input.resource.fileName ?? path.basename(attachmentPath); break; } case "project-favicon": { @@ -464,7 +484,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => Option.none()), ); return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: attachmentPath, + ...(claims.download ? { download: true } : {}), + ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}), + ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}), + } satisfies ResolvedAsset) : null; } diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index cb08d5e4b2..6fffa1d1f9 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -4,11 +4,16 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { base64UrlEncode, signPayload } from "../auth/utils.ts"; import * as ServerConfig from "../config.ts"; import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; import { @@ -30,6 +35,19 @@ const uploadInput = { sizeBytes: 6, } as const; +const LegacyAttachmentUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect( + Schema.fromJsonString(LegacyAttachmentUploadClaims), +); + describe("AttachmentUpload", () => { it.effect("signs the attachment metadata and validates the upload token", () => Effect.gen(function* () { @@ -59,6 +77,31 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("accepts unexpired image upload tokens issued before file support", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const encodedPayload = base64UrlEncode( + yield* encodeLegacyAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: uploadInput.name, + mimeType: uploadInput.mimeType, + sizeBytes: uploadInput.sizeBytes, + expiresAt: issued.expiresAt, + }), + ); + const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + + expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({ + type: "image", + attachmentId: issued.attachmentId, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects expired upload tokens", () => Effect.gen(function* () { const issued = yield* issueAttachmentUploadUrl(uploadInput); @@ -108,6 +151,85 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("streams generic files to a path with their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl({ + type: "file", + name: "report.PDF", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect( + yield* storeAttachmentUpload( + claims, + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + ), + ).toEqual({ ok: true }); + expect(issued.attachmentId).toMatch(/-pdf$/); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)), + ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6])); + + yield* deletePendingAttachment(issued.attachmentId); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes partial streamed uploads that exceed their signed size", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({ + ok: false, + status: 400, + }); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes partial streamed uploads when the upload is interrupted", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const nextChunkRequested = yield* Deferred.make(); + const body = Stream.make(new Uint8Array([1, 2, 3])).pipe( + Stream.concat( + Stream.fromEffect( + Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)), + ), + ), + ); + const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped); + + yield* Deferred.await(nextChunkRequested); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), + ).toHaveLength(1); + + yield* Fiber.interrupt(upload); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("deletes pending uploads without deleting thread-owned copies", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index 6142b69d73..ba3539a3df 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { + attachmentFileExtension, createPendingAttachmentId, parseThreadSegmentFromAttachmentId, PENDING_ATTACHMENT_THREAD_SEGMENT, @@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map(); const AttachmentUploadClaims = Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment-upload"), + type: Schema.Literals(["image", "file"]).pipe( + Schema.withDecodingDefault(Effect.succeed("image" as const)), + ), attachmentId: Schema.String, name: Schema.String, mimeType: Schema.String, @@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f } } - const attachmentId = createPendingAttachmentId(); + const attachmentType = input.type ?? "image"; + const attachmentId = createPendingAttachmentId( + attachmentType === "file" ? attachmentFileExtension(input.name) : undefined, + ); const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; const encodedPayload = base64UrlEncode( encodeAttachmentUploadClaims({ version: 1, kind: "attachment-upload", + type: attachmentType, attachmentId, name: input.name, mimeType: input.mimeType, @@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult = export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( claims: AttachmentUploadClaims, - bytes: Uint8Array, + body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"], ) { - if (bytes.byteLength !== claims.sizeBytes) { + if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) { return { ok: false, status: 400, - detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`, } satisfies StoreAttachmentUploadResult; } const config = yield* ServerConfig.ServerConfig; - const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const extension = + claims.type === "file" + ? attachmentFileExtension(claims.name) + : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); const relativePath = `${claims.attachmentId}${extension}`; const finalPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, @@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + let receivedBytes = 0; + const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body; return yield* Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); - yield* fileSystem.writeFile(partPath, bytes); + yield* Stream.run( + bodyStream.pipe( + Stream.takeWhile((chunk) => { + receivedBytes += chunk.byteLength; + return receivedBytes <= claims.sizeBytes; + }), + ), + fileSystem.sink(partPath), + ); + if (receivedBytes !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } yield* fileSystem.rename(partPath, finalPath); return { ok: true } satisfies StoreAttachmentUploadResult; }).pipe( Effect.catch((cause) => - fileSystem.remove(partPath, { force: true }).pipe( - Effect.orElseSucceed(() => undefined), - Effect.andThen( - Effect.logError("Failed to persist attachment upload.", { - attachmentId: claims.attachmentId, - cause, - }), - ), + Effect.logError("Failed to persist attachment upload.", { + attachmentId: claims.attachmentId, + cause, + }).pipe( Effect.as({ ok: false, status: 500, @@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio } satisfies StoreAttachmentUploadResult), ), ), + Effect.ensuring( + fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)), + ), ); }); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 5e782e5540..59374f18ba 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,9 +6,11 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + attachmentFileExtension, createAttachmentId, createPendingAttachmentId, parseAttachmentUuid, + parseAttachmentFileExtension, planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, @@ -58,6 +60,21 @@ describe("attachmentStore", () => { ); }); + it("preserves safe file extensions in attachment ids and paths", () => { + const attachmentId = createPendingAttachmentId(".PDF"); + + expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending"); + expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf"); + expect(attachmentFileExtension("report.PDF")).toBe(".pdf"); + expect(attachmentFileExtension("report")).toBe(".bin"); + expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin"); + // ".part" is the in-flight upload suffix; storing it would make the file + // look like a stale partial to the sweep. + expect(attachmentFileExtension("archive.part")).toBe(".bin"); + expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -92,6 +109,21 @@ describe("attachmentStore", () => { } }); + it("resolves generic attachments without scanning the attachment directory", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"), + ); + try { + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip"; + const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`); + NodeFS.writeFileSync(archivePath, Buffer.from("archive")); + + expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("plans pending attachment claims with direct filename lookups", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), @@ -147,15 +179,17 @@ describe("attachmentStore", () => { const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; const uuid = "00000000-0000-4000-8000-000000000002"; const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`); const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); - for (const filePath of [pendingPath, threadPath, partialPath]) { + for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) { NodeFS.writeFileSync(filePath, Buffer.from("pixels")); NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); } - expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 }); expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(pendingFilePath)).toBe(false); expect(NodeFS.existsSync(partialPath)).toBe(false); expect(NodeFS.existsSync(threadPath)).toBe(true); } finally { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index d0334bce09..261b094645 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; const ATTACHMENT_ID_PATTERN = new RegExp( - `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, + `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`, "i", ); @@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; } -export function createPendingAttachmentId(): string { - return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +export function attachmentFileExtension(fileName: string): string { + const extension = NodePath.extname(fileName).toLowerCase(); + // ".part" is reserved for in-flight uploads; a stored "archive.part" would + // look stale to sweepStalePendingAttachments and get deleted. + if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) { + return ".bin"; + } + return extension; +} + +function attachmentIdExtensionSuffix(extension: string | undefined): string { + if (!extension) { + return ""; + } + const normalized = extension.replace(/^\./, "").toLowerCase(); + return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized) + ? `-${normalized}` + : "-bin"; +} + +export function createPendingAttachmentId(extension?: string): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseAttachmentUuid(attachmentId: string): string | null { @@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null { return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } -export function createAttachmentId(threadId: string): string | null { +export function parseAttachmentFileExtension(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null; +} + +export function createAttachmentId(threadId: string, extension?: string): string | null { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { return null; } - return `${threadSegment}-${NodeCrypto.randomUUID()}`; + return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string return match[1]?.toLowerCase() ?? null; } -export function attachmentRelativePath(attachment: ChatAttachment): string { +/** Null for attachment types this build does not know; callers skip those. */ +export function attachmentRelativePath(attachment: ChatAttachment): string | null { switch (attachment.type) { case "image": { const extension = inferImageExtension({ @@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": + return `${attachment.id}${attachmentFileExtension(attachment.name)}`; + default: + return null; } } @@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: { readonly attachmentsDir: string; readonly attachment: ChatAttachment; }): string | null { + const relativePath = attachmentRelativePath(input.attachment); + if (!relativePath) { + return null; + } return resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, - relativePath: attachmentRelativePath(input.attachment), + relativePath, }); } @@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: { if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { return null; } + const fileExtension = parseAttachmentFileExtension(normalizedId); + if (fileExtension) { + const filePath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`, + }); + return filePath && NodeFS.existsSync(filePath) ? filePath : null; + } for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { const maybePath = resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, @@ -147,7 +193,8 @@ export function planAttachmentClaim(input: { if (!currentPath) { return { ok: false, reason: "attachment not found (removed or expired)" }; } - const finalId = createAttachmentId(input.threadId); + const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined; + const finalId = createAttachmentId(input.threadId, fileExtension); if (!finalId) { return { ok: false, reason: "failed to create attachment id" }; } diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb05634214..f5d244dd96 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -16,6 +16,8 @@ import { type ServerAuthDescriptor, type ServerAuthSessionMethod, type AuthWebSocketTicketResult, + DpopFailureReason, + type DpopFailureReason as DpopFailureReasonType, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; +export const serverAuthDpopFailureReason = ( + error: ServerAuthCredentialError, +): DpopFailureReasonType | undefined => + error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined; + export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthInvalidScopeError", {}, @@ -606,6 +614,7 @@ export const make = Effect.gen(function* () { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", + dpopFailureReason: "invalid_proof", }), ); } @@ -623,6 +632,7 @@ export const make = Effect.gen(function* () { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP authorization requires a proof-bound access token.", + dpopFailureReason: "invalid_proof", }), ); } diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0..ea8d1cd99d 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => { expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { expect(error.cause).toBe(cause); + expect(error.dpopFailureReason).toBe("replay"); } }); @@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => { } }); }); + +describe("mapDpopFailureReason", () => { + it("maps verifier failures to safe client-facing categories", () => { + const mappings = [ + ["time_window", "time_window"], + ["key_mismatch", "key_mismatch"], + ["method_mismatch", "request_mismatch"], + ["url_mismatch", "request_mismatch"], + ["access_token_hash_mismatch", "token_mismatch"], + ["missing_proof", "invalid_proof"], + ["malformed_proof", "invalid_proof"], + ["invalid_signature", "invalid_proof"], + ["invalid_proof", "invalid_proof"], + ] as const; + + for (const [code, expected] of mappings) { + expect(mapDpopFailureReason(code)).toBe(expected); + } + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index f19984eb36..43f90e4409 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -1,4 +1,8 @@ -import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { + type DpopVerificationFailureCode as DpopVerificationFailureCodeType, + verifyDpopProof, +} from "@t3tools/shared/dpop"; +import type { DpopFailureReason } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -14,12 +18,32 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { + switch (code) { + case "time_window": + return "time_window"; + case "key_mismatch": + return "key_mismatch"; + case "method_mismatch": + case "url_mismatch": + return "request_mismatch"; + case "access_token_hash_mismatch": + return "token_mismatch"; + case "missing_proof": + case "malformed_proof": + case "invalid_signature": + case "invalid_proof": + return "invalid_proof"; + } +}; + export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ diagnostic: "DPoP proof replayed.", + dpopFailureReason: "replay", cause: error, }) : new ServerAuthDpopReplayStateRecordError({ @@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": result.code, + }); return yield* new ServerAuthInvalidCredentialError({ diagnostic: result.reason, + dpopFailureReason: mapDpopFailureReason(result.code), }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: { ) .pipe( Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.fail(mapDpopReplayStoreError(error)), + Effect.gen(function* () { + const mapped = mapDpopReplayStoreError(error); + if (mapped._tag === "ServerAuthInvalidCredentialError") { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": mapped.dpopFailureReason, + }); + } + return yield* Effect.fail(mapped); + }), ), ); return result.thumbprint; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde2..58277141a9 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -22,7 +22,7 @@ import { EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; +import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; @@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) { }); } -export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { +export function failEnvironmentAuthInvalid( + reason: EnvironmentAuthInvalidReason, + dpopFailureReason?: DpopFailureReason, +) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => - Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })), + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason, + ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }), + traceId, + }), + ), ), ); } @@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( return result.response; }, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("browser_session_issuance_failed", error), @@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + Effect.andThen( + failEnvironmentAuthInvalid( + "invalid_credential", + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => @@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts deleted file mode 100644 index 4c8b9c59cc..0000000000 --- a/apps/server/src/checkpointing/Errors.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; - -import { - CheckpointRefUnavailableError, - CheckpointTurnRangeUnavailableError, - CheckpointWorkspacePathMissingError, -} from "./Errors.ts"; - -const threadId = ThreadId.make("thread-1"); - -it("derives checkpoint messages from structured context", () => { - const range = new CheckpointTurnRangeUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - requestedTurnCount: 4, - availableTurnCount: 2, - }); - const checkpoint = new CheckpointRefUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - turnCount: 2, - checkpoint: "to", - }); - const workspace = new CheckpointWorkspacePathMissingError({ - operation: "CheckpointDiffQuery.getFullThreadDiff", - threadId, - }); - - expect(range.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", - ); - expect(checkpoint.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", - ); - expect(workspace.message).toBe( - "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", - ); -}); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2f03859878..3d5a8722e5 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9d4e56aa5b..3c2bbb65d3 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { + EnvironmentId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -147,6 +151,7 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, attachmentUploads: true, + fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, threadSnooze: true, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index f85de08d40..edb3967a07 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,12 @@ import { expect, it } from "@effect/vitest"; import { describe } from "vite-plus/test"; -import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + assetResponseHeaders, + downloadContentDisposition, + isLoopbackHostname, + resolveDevRedirectUrl, +} from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -55,4 +60,79 @@ describe("assetResponseHeaders", () => { "text/html; charset=utf-8", ); }); + + it("downloads uploaded documents without executing their content", () => { + expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({ + "Content-Disposition": "attachment", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/octet-stream", + }); + }); + + it("serves the real filename and mime type when the claims carry them", () => { + expect( + assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", { + download: true, + fileName: "Q3 report.pdf", + mimeType: "application/pdf", + }), + ).toMatchObject({ + "Content-Disposition": 'attachment; filename="Q3 report.pdf"', + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/pdf", + }); + }); + + it("keeps renderable mime types as octet-stream downloads", () => { + for (const mimeType of [ + "text/html", + "text/xml", + "image/svg+xml", + "application/xhtml+xml", + "application/rss+xml", + "APPLICATION/XML", + "IMAGE/SVG+XML", + "application/xml-dtd", + "application/xml-external-parsed-entity", + "not a mime", + ]) { + expect( + assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }), + ).toHaveProperty("Content-Type", "application/octet-stream"); + } + }); + + it("preserves official Office Open XML mime types", () => { + for (const mimeType of [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ]) { + expect( + assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }), + ).toHaveProperty("Content-Type", mimeType); + } + }); +}); + +describe("downloadContentDisposition", () => { + it("quotes plain names and strips quotes and control characters", () => { + expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"'); + expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"'); + }); + + it("adds an RFC 5987 encoded name for non-ASCII filenames", () => { + expect(downloadContentDisposition("répört.pdf")).toBe( + `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`, + ); + expect(downloadContentDisposition("résumé'(*).pdf")).toBe( + `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`, + ); + }); + + it("does not throw on unpaired surrogates in the filename", () => { + expect(downloadContentDisposition("bad\ud800name.pdf")).toBe( + `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`, + ); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 16a558560a..5bb6ba8dc3 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { HttpBody, @@ -52,15 +53,57 @@ const INDEX_HTML_FILE_NAME = "index.html"; const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -export function assetResponseHeaders(filePath: string): Record { +// Types a browser may render as a document if a proxy strips the disposition +// header. Downloads of these fall back to octet-stream. +const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; +const isSafeDownloadMimeType = (mimeType: string): boolean => + DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && + !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); + +/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ +export function downloadContentDisposition(fileName?: string): string { + if (fileName === undefined) { + return "attachment"; + } + // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. + // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. + const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); + const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); + const needsExtended = asciiFallback !== sanitized; + const extendedName = encodeURIComponent(sanitized).replace( + /['()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `attachment; filename="${asciiFallback}"${ + needsExtended ? `; filename*=UTF-8''${extendedName}` : "" + }`; +} + +export function assetResponseHeaders( + filePath: string, + options?: { + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + }, +): Record { const lowerPath = filePath.toLowerCase(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } - : {}), - ...(lowerPath.endsWith(".svg") + ...(options?.download + ? { + "Content-Disposition": downloadContentDisposition(options.fileName), + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": + options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType) + ? options.mimeType + : "application/octet-stream", + } + : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { "Content-Type": "text/html; charset=utf-8" } + : {}), + ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; @@ -127,7 +170,10 @@ const authenticateRawRouteWithScope = ( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -238,7 +284,16 @@ export const assetRouteLayer = HttpRouter.add( } return yield* HttpServerResponse.file(asset.path, { status: 200, - headers: assetResponseHeaders(asset.path), + headers: assetResponseHeaders( + asset.path, + asset.download + ? { + download: true, + ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), + ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), + } + : undefined, + ), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); @@ -275,15 +330,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }); } - const body = yield* request.arrayBuffer.pipe( - Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), - Effect.orElseSucceed(() => null), - ); - if (body === null) { - return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); - } - - const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + // Keep the request stream in the route scope until the response is sent. + const bodyPull = yield* Stream.toPull(request.stream); + const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull))); return stored.ok ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text(stored.detail, { status: stored.status }); diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1f05604934..391e040f6d 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -196,6 +196,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); + assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts deleted file mode 100644 index d9ed2e1271..0000000000 --- a/apps/server/src/observability/Attributes.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { normalizeModelMetricLabel } from "./Attributes.ts"; - -describe("Attributes", () => { - it("groups GPT-family models under a shared metric label", () => { - assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("claude-sonnet-4"), "claude"); - }); -}); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 0b56ba2031..5ce4b70cd5 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -113,6 +113,7 @@ describe("OrchestrationEngine", () => { detail: "historical replay should not be used during bootstrap", }), ), + hasEventAfter: () => Effect.succeed(false), }; const projectionSnapshot = { @@ -818,6 +819,7 @@ describe("OrchestrationEngine", () => { readAll() { return Stream.fromIterable(events); }, + hasEventAfter: () => Effect.succeed(false), }; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1054,6 +1056,7 @@ describe("OrchestrationEngine", () => { readAll() { return Stream.fromIterable(events); }, + hasEventAfter: () => Effect.succeed(false), }; let shouldFailProjection = true; diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb..b05ce3b1e2 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -61,7 +61,7 @@ describe("OrchestrationReactor", () => { started.push("thread-deletion-reactor"); return Effect.void; }, - drain: Effect.void, + drainThrough: () => Effect.void, }), ), Layer.provideMerge( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index b15d2f679e..57215803f4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -9,6 +9,7 @@ import { TurnId, ProviderInstanceId, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -31,6 +32,7 @@ import { OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -882,6 +884,7 @@ it.layer( const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Revert.Files"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; @@ -983,6 +986,13 @@ it.layer( mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: keepFileAttachmentId, + name: "keep.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, ], turnId: TurnId.make("turn-keep"), streaming: false, @@ -1045,9 +1055,11 @@ it.layer( }); const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); + const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`); const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(keepFilePath, "keep"); yield* fileSystem.writeFileString(removePath, "remove"); const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); yield* fileSystem.writeFileString(otherThreadPath, "other"); @@ -1072,6 +1084,7 @@ it.layer( }); assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(keepFilePath)); assert.isFalse(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); }), @@ -1091,6 +1104,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Delete.Files"); const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; @@ -1169,6 +1183,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: fileAttachmentId, + name: "delete.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, ], turnId: null, streaming: false, @@ -1178,14 +1199,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`); const otherThreadAttachmentPath = path.join( attachmentsDir, `${otherThreadAttachmentId}.png`, ); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); + yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete"); yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); assert.isTrue(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); yield* appendAndProject({ @@ -1205,6 +1229,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isFalse(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); }), ); @@ -1254,7 +1279,183 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("replaying a superseded thread.deleted keeps the re-created thread's files", () => + 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 = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-replay"); + const retriedThreadId = ThreadId.make("thread-replay-retried"); + const goneThreadId = ThreadId.make("thread-replay-gone"); + const retriedAttachmentPath = path.join( + attachmentsDir, + "thread-replay-retried-00000000-0000-4000-8000-000000000001.png", + ); + const goneAttachmentPath = path.join( + attachmentsDir, + "thread-replay-gone-00000000-0000-4000-8000-000000000002.png", + ); + const threadCreated = (threadId: ThreadId, suffix: string) => + eventStore.append({ + type: "thread.created", + eventId: EventId.make(`evt-replay-create-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-replay-create-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-replay-create-${suffix}`), + metadata: {}, + payload: { + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + const threadDeleted = (threadId: ThreadId, suffix: string) => + eventStore.append({ + type: "thread.deleted", + eventId: EventId.make(`evt-replay-delete-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-replay-delete-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-replay-delete-${suffix}`), + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-replay-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-replay-project"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-replay-project"), + metadata: {}, + payload: { + projectId, + title: "Replay", + workspaceRoot: "/tmp/project-replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + // A failed first send: create, roll back, then the draft retries the id. + yield* threadCreated(retriedThreadId, "retried-1"); + yield* threadDeleted(retriedThreadId, "retried"); + yield* threadCreated(retriedThreadId, "retried-2"); + // A thread that was deleted for good. + yield* threadCreated(goneThreadId, "gone"); + yield* threadDeleted(goneThreadId, "gone"); + + // Files on disk are not event-sourced: by the time anything replays, + // the retried thread's attachments already belong to its second life. + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(retriedAttachmentPath, "second incarnation"); + yield* fileSystem.writeFileString(goneAttachmentPath, "gone"); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(retriedAttachmentPath)); + assert.isFalse(yield* exists(goneAttachmentPath)); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { + it.effect("replays a bootstrap backlog larger than the event store default limit", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-bootstrap-backlog"); + + const sequenceRows = yield* sql<{ readonly maxSequence: number | null }>` + SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events + `; + const sequenceBeforeBacklog = sequenceRows[0]?.maxSequence ?? 0; + const appendedEvents = yield* Effect.forEach( + Array.from({ length: 1_001 }, (_, index) => index), + (index) => { + const eventId = EventId.make(`evt-bootstrap-backlog-${index}`); + const commandId = CommandId.make(`cmd-bootstrap-backlog-${index}`); + return eventStore.append({ + type: "project.created", + eventId, + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId, + causationEventId: null, + correlationId: CorrelationId.make(commandId), + metadata: {}, + payload: { + projectId, + title: `Bootstrap backlog ${index}`, + workspaceRoot: "/tmp/project-bootstrap-backlog", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + }, + ); + const lastSequence = appendedEvents[appendedEvents.length - 1]!.sequence; + + yield* Effect.forEach( + Object.values(ORCHESTRATION_PROJECTOR_NAMES), + (projector) => { + const lastAppliedSequence = + projector === ORCHESTRATION_PROJECTOR_NAMES.projects + ? sequenceBeforeBacklog + : lastSequence; + return sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, ${lastAppliedSequence}, ${now}) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `; + }, + { discard: true }, + ); + + yield* projectionPipeline.bootstrap; + + const stateRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.projects} + `; + assert.deepEqual(stateRows, [{ lastAppliedSequence: lastSequence }]); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -2756,7 +2957,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const engineLayer = it.layer( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), @@ -2873,4 +3074,147 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { ]); }), ); + + it.effect("re-creating a deleted thread id starts from an empty projection", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-retry"); + const threadId = ThreadId.make("thread-retry"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const createThread = (commandId: string, title: string) => + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(commandId), + threadId, + projectId, + title, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + const countRowsForThread = (table: string) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM ${sql(table)} WHERE thread_id = ${threadId} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + const perThreadTables = [ + "projection_thread_messages", + "projection_thread_activities", + "projection_thread_sessions", + "projection_turns", + "projection_thread_proposed_plans", + "projection_pending_approvals", + ]; + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-retry-project"), + projectId, + title: "Retry Project", + workspaceRoot: "/tmp/project-retry", + defaultModelSelection: modelSelection, + createdAt, + }); + + // First attempt: the thread gets a turn, a message, an activity, and a + // running session before its bootstrap fails and the server rolls back. + yield* createThread("cmd-retry-create-1", "First attempt"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-retry-turn-1"), + threadId, + message: { + messageId: MessageId.make("message-retry-1"), + role: "user", + text: "first attempt", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-retry-activity-1"), + threadId, + activity: { + id: EventId.make("activity-retry-1"), + tone: "info", + kind: "approval.requested", + summary: "approval requested", + payload: { requestId: "request-retry-1" }, + turnId: null, + createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: CommandId.make("cmd-retry-plan-1"), + threadId, + proposedPlan: { + id: "plan-retry-1", + turnId: null, + planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, + createdAt, + updatedAt: createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-retry-session-1"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-retry-1"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + for (const table of perThreadTables) { + assert.isAbove(yield* countRowsForThread(table), 0, `${table} should be populated`); + } + const populatedShell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); + assert.isTrue(populatedShell.hasPendingApprovals); + assert.isTrue(populatedShell.hasActionableProposedPlan); + + yield* engine.dispatch({ + type: "thread.delete", + commandId: CommandId.make("cmd-retry-delete"), + threadId, + }); + assert.isTrue(Option.isNone(yield* snapshotQuery.getThreadShellById(threadId))); + + // Retry from the same draft reuses the thread id. + yield* createThread("cmd-retry-create-2", "Second attempt"); + + const shell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); + assert.strictEqual(shell.title, "Second attempt"); + assert.isFalse(shell.hasPendingApprovals); + assert.isFalse(shell.hasActionableProposedPlan); + for (const table of perThreadTables) { + assert.strictEqual(yield* countRowsForThread(table), 0, `${table} should be empty`); + } + const detail = Option.getOrThrow(yield* snapshotQuery.getThreadDetailById(threadId)); + assert.deepEqual(detail.messages, []); + assert.deepEqual(detail.activities, []); + assert.isNull(detail.latestTurn); + assert.isNull(detail.session); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 15a4c77d16..3dd868abda 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -346,14 +346,14 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; } - relativePaths.add(attachmentRelativePath(attachment)); + const relativePath = attachmentRelativePath(attachment); + if (relativePath) { + relativePaths.add(relativePath); + } } } return relativePaths; @@ -855,7 +855,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.deleted": { - attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + // A draft retry can re-create this id later in the log. During + // replay the attachment files on disk already belong to that later + // incarnation, so only an unsuperseded deletion removes them. + const recreatedLater = yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: event.payload.threadId, + type: "thread.created", + sequenceExclusive: event.sequence, + }); + if (!recreatedLater) { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + } const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -1137,6 +1148,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + // A draft retry re-creates a soft-deleted thread id. Every projector + // drops its own rows for the old incarnation here so replay from any + // per-projector cursor rebuilds the new thread without stale history. + case "thread.created": + yield* projectionThreadMessageRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.message-sent": { const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, @@ -1216,6 +1236,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadProposedPlansProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.proposed-plan-upserted": yield* projectionThreadProposedPlanRepository.upsert({ planId: event.payload.proposedPlan.id, @@ -1267,6 +1293,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadActivitiesProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionThreadActivityRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.activity-appended": yield* projectionThreadActivityRepository.upsert({ activityId: event.payload.activity.id, @@ -1318,6 +1350,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadSessionsProjection", )(function* (event, _attachmentSideEffects) { + if (event.type === "thread.created") { + yield* projectionThreadSessionRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + } if (event.type !== "thread.session-set") { return; } @@ -1337,6 +1375,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadTurnsProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.turn-start-requested": { yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, @@ -1674,6 +1718,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyPendingApprovalsProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionPendingApprovalRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.activity-appended": { const requestId = extractActivityRequestId(event.payload.activity.payload) ?? @@ -1877,6 +1927,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Stream.runForEach( eventStore.readFromSequence( Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, + Number.MAX_SAFE_INTEGER, ), (event) => runProjectorForEvent(projector, event), ), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 033b725fc2..a804ac0f5b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2986,15 +2986,15 @@ describe("ProviderCommandReactor", () => { }); }); - it("surfaces stale provider approval request failures without faking approval resolution", async () => { + it("normalizes stale Codex approval callbacks without faking approval resolution", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ provider: ProviderDriverKind.make("codex"), - method: "session/request_permission", - detail: "Unknown pending permission request: approval-request-1", + method: "item/requestApproval/decision", + detail: "Unknown pending Codex approval request: approval-request-1", }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e7add28529..386a42509d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -244,13 +244,15 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause { const threadId = ThreadId.make("thread-deletion-reactor-test"); @@ -36,3 +61,79 @@ describe("logCleanupCauseUnlessInterrupted", () => { } }); }); + +describe("ThreadDeletionReactor drain", () => { + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-deletion-reactor-drain"); + const deletedEvent = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-deleted-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.deleted", + occurredAt: now, + commandId: CommandId.make(`cmd-deleted-${sequence}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-deleted-${sequence}`), + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + + effectIt.effect("waits for a published deletion the subscriber has not consumed yet", () => + Effect.gen(function* () { + const stops: Array = []; + const firstCleanupDone = yield* Deferred.make(); + // The engine has already committed and published sequence 2, but the + // subscriber has not received it yet: the stream releases it on demand. + const releaseSecondEvent = yield* Deferred.make(); + const latestSequence = yield* Ref.make(0); + const engine = { + latestSequence: Ref.get(latestSequence), + streamDomainEvents: Stream.concat( + Stream.make(deletedEvent(1)), + Stream.fromEffect(Deferred.await(releaseSecondEvent)).pipe( + Stream.map(() => deletedEvent(2)), + ), + ), + } as unknown as OrchestrationEngineShape; + const providerService = { + stopSession: () => + Effect.gen(function* () { + stops.push(stops.length + 1); + if (stops.length === 1) { + yield* Deferred.succeed(firstCleanupDone, undefined); + } + }), + } as unknown as ProviderServiceShape; + const terminalManager = { + close: () => Effect.void, + } as unknown as TerminalManager.TerminalManager["Service"]; + const layer = ThreadDeletionReactorLive.pipe( + Layer.provide(Layer.succeed(ProviderService, providerService)), + Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* Deferred.await(firstCleanupDone); + + // Sequence 1 is fully cleaned and the worker queue is idle. Sequence + // 2 is committed and published but still in flight to the subscriber. + yield* Ref.set(latestSequence, 2); + const drained = yield* Effect.forkChild(reactor.drainThrough(2)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + expect(stops).toEqual([1]); + expect(drained.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(releaseSecondEvent, undefined); + yield* Fiber.join(drained); + expect(stops).toEqual([1, 2]); + }), + ).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index a026f5ad81..14a92a5eae 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; @@ -80,20 +81,43 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + // Highest event sequence the subscriber has handed to the worker. Waiting + // through a successful thread.created sequence covers every deletion that + // was ahead of that create in the engine queue; the worker drain then covers + // the in-flight cleanup. + const seenSequence = yield* SubscriptionRef.make(0); + const noteSeen = (sequence: number) => + SubscriptionRef.update(seenSequence, (seen) => Math.max(seen, sequence)); + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { yield* forkParked( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.deleted") { - return Effect.void; - } - return worker.enqueue(event); - }), + Stream.runForEach( + orchestrationEngine.streamDomainEvents.pipe( + // Events that landed before the subscription are not replayed, so + // start the watermark at the current head instead of zero. + Stream.onStart(orchestrationEngine.latestSequence.pipe(Effect.flatMap(noteSeen))), + ), + (event) => + (event.type === "thread.deleted" ? worker.enqueue(event) : Effect.void).pipe( + Effect.andThen(noteSeen(event.sequence)), + ), + ), + ); + }); + + const drainThrough: ThreadDeletionReactorShape["drainThrough"] = Effect.fn( + "ThreadDeletionReactor.drainThrough", + )(function* (target) { + yield* SubscriptionRef.changes(seenSequence).pipe( + Stream.filter((seen) => seen >= target), + Stream.runHead, ); + yield* worker.drain; }); return { start, - drain: worker.drain, + drainThrough, } satisfies ThreadDeletionReactorShape; }); diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 27a35977ff..7385b65315 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => { expect(attachmentId.startsWith("thread-1-")).toBe(true); expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`); expect(NodeFS.existsSync(pendingPath)).toBe(true); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( - true, - ); + const claimedPngPath = NodePath.join(config.attachmentsDir, `${attachmentId}.png`); + expect(NodeFS.existsSync(claimedPngPath)).toBe(true); + // A copy, not a hard link: editing the delivered file must not mutate + // the retryable pending upload. + expect(NodeFS.statSync(claimedPngPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino); + expect(NodeFS.readFileSync(claimedPngPath)).toEqual(bytes); }).pipe(Effect.provide(testLayer)), ); @@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("claims uploaded documents without changing their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}-pdf`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.pdf`); + NodeFS.writeFileSync(pendingPath, Buffer.from("report")); + + const imageCommand = turnStartCommand({ attachments: [] }); + if (imageCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const normalized = yield* normalizeDispatchCommand({ + ...imageCommand, + message: { + ...imageCommand.message, + attachments: [ + { + type: "file", + id: pendingId, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, + ], + }, + }); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachment = normalized.message.attachments[0]!; + expect(attachment.type).toBe("file"); + expect(attachment.id).toMatch(/^thread-1-.*-pdf$/); + const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.pdf`); + expect(NodeFS.readFileSync(claimedPath)).toEqual(Buffer.from("report")); + expect(NodeFS.statSync(claimedPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("retries a failed bootstrap with a fresh thread id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => { })), }, }).pipe(Effect.flip); - expect(mismatchedType.message).toContain("image type"); + expect(mismatchedType.message).toContain("attachment type"); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bd6a8f242b..1226a6cd25 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); if (expectedPath !== claim.finalPath) { return yield* new OrchestrationDispatchCommandError({ - message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, + message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } // Keep the pending copy until the turn succeeds. A failed thread - // bootstrap can then retry with a fresh thread id. + // bootstrap can then retry with a fresh thread id. A copy, not a + // hard link: an agent editing the delivered file in place must not + // mutate the retry source. yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts index 7c6718965a..cdbb70919a 100644 --- a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -23,10 +23,12 @@ export interface ThreadDeletionReactorShape { readonly start: () => Effect.Effect; /** - * Resolves when the internal processing queue is empty and idle. - * Intended for test use to replace timing-sensitive sleeps. + * Resolves once every thread.deleted at or before the supplied event + * sequence has been handed to the worker and the worker is empty and idle. + * A successful thread.create sequence is the fence callers use before the + * new incarnation can own runtime resources. */ - readonly drain: Effect.Effect; + readonly drainThrough: (sequence: number) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 4a4b68ced5..b4c528480f 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; describe("ThreadBackgroundLiveness", () => { - it("does not let status-free progress restart an idle task", () => { + it("does not let status-free progress or metadata restart an idle task", () => { const liveness = ThreadBackgroundLiveness.make(); liveness.recordTaskLiveness({ threadId: "thread", @@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => { status: undefined, kind: "progress", }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: "completed", + kind: "completed", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "updated", + }); expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); }); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index d4d6da06df..2781e4981f 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return; } - // Status-free progress is a description tick, not a restart. A delayed - // progress event after idle must not put the task back in the live set - // (#7128). - if (input.kind === "progress" && input.status === undefined) { + // Status-free progress and metadata updates are not restarts. A delayed + // row after idle must not put the task back in the live set (#7128). + if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) { const existing = stateByThreadId.get(input.threadId); const stillLive = existing !== undefined && diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 214bfa6ae1..291d23d02b 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -201,4 +201,34 @@ describe("commandInvariants", () => { ), ).rejects.toThrow("already exists"); }); + + it("lets a draft retry re-create a thread id after its first attempt was deleted", async () => { + const threadId = ThreadId.make("thread-1"); + const firstAttempt = readModel.threads.find((thread) => thread.id === threadId)!; + const afterRollback: OrchestrationReadModel = { + ...readModel, + threads: readModel.threads.map((thread) => + thread.id === threadId ? { ...thread, deletedAt: now, updatedAt: now } : thread, + ), + }; + const retry: OrchestrationCommand = { + type: "thread.create", + commandId: CommandId.make("cmd-retry"), + threadId, + projectId: firstAttempt.projectId, + title: firstAttempt.title, + modelSelection: firstAttempt.modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }; + + await expect( + Effect.runPromise( + requireThreadAbsent({ readModel: afterRollback, command: retry, threadId }), + ), + ).resolves.toBeUndefined(); + }); }); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4..8f1e3e8985 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -156,7 +156,11 @@ export function requireThreadAbsent(input: { readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { - if (!findThreadById(input.readModel, input.threadId)) { + // Thread deletion is a soft delete and a draft keeps its client-minted id + // across retries, so only a live row blocks creation. Projectors reset the + // thread's rows when the id is created again. + const existing = findThreadById(input.readModel, input.threadId); + if (existing === undefined || existing.deletedAt !== null) { return Effect.void; } return Effect.fail( diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa57..edd8620c3b 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -15,6 +15,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -60,6 +61,13 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ metadata: EventMetadataFromJsonString, }); +const HasEventAfterRequestSchema = Schema.Struct({ + aggregateKind: Schema.String, + aggregateId: Schema.String, + type: Schema.String, + sequenceExclusive: NonNegativeInt, +}); + const ReadFromSequenceRequestSchema = Schema.Struct({ sequenceExclusive: NonNegativeInt, limit: Schema.Number, @@ -260,10 +268,37 @@ const makeEventStore = Effect.gen(function* () { return readPage(sequenceExclusive, normalizedLimit); }; + const findEventAfter = SqlSchema.findOneOption({ + Request: HasEventAfterRequestSchema, + Result: Schema.Struct({ sequence: Schema.Number }), + execute: (request) => + sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND event_type = ${request.type} + AND sequence > ${request.sequenceExclusive} + LIMIT 1 + `, + }); + + const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => + findEventAfter(input).pipe( + Effect.map(Option.isSome), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.hasEventAfter:query", + "OrchestrationEventStore.hasEventAfter:decodeRow", + ), + ), + ); + return { append, readFromSequence, readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), + hasEventAfter, } satisfies OrchestrationEventStoreShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 253f6e13b9..3b159a9e17 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -95,6 +95,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { `, }); + const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({ + Request: ListProjectionPendingApprovalsInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_pending_approvals + WHERE thread_id = ${threadId} + `, + }); + const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => upsertProjectionPendingApprovalRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), @@ -123,11 +132,19 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { ), ); + const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionPendingApprovalRowsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByThreadId:query"), + ), + ); + return { upsert, listByThreadId, getByRequestId, deleteByRequestId, + deleteByThreadId, } satisfies ProjectionPendingApprovalRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 8b465e7713..488210ab74 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -52,6 +52,19 @@ export interface OrchestrationEventStoreShape { * @returns Stream containing all stored events. */ readonly readAll: () => Stream.Stream; + + /** + * Check whether an aggregate has an event of the given type after a sequence. + * + * Used during replay to tell whether a later event supersedes the one being + * applied, without streaming the rest of the log. + */ + readonly hasEventAfter: (input: { + readonly aggregateKind: OrchestrationEvent["aggregateKind"]; + readonly aggregateId: string; + readonly type: OrchestrationEvent["type"]; + readonly sequenceExclusive: number; + }) => Effect.Effect; } /** diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 967e6da9d3..40b0d1ae03 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -82,6 +82,13 @@ export interface ProjectionPendingApprovalRepositoryShape { readonly deleteByRequestId: ( input: DeleteProjectionPendingApprovalInput, ) => Effect.Effect; + + /** + * Delete every pending approval row for a thread. + */ + readonly deleteByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f110131..0b4e957fe1 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts new file mode 100644 index 0000000000..3536a37a99 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; + +const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); + +describe("parseGrokInspectSkills", () => { + it("maps inspect entries onto provider skills, sorted by name", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "deploy", + description: "Deploy the app.", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, + }, + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("disables skills the CLI marks as not user-invocable", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "internal-helper", + source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, + userInvocable: false, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "internal-helper", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, + }, + ]); + }); + + it("skips entries without a name or a filesystem path", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ); + + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + + it("returns an empty list for malformed or unexpected output", () => { + expect(parseGrokInspectSkills("not json")).toEqual([]); + expect(parseGrokInspectSkills("null")).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); + }); +}); + +describe("discoverGrokSkills", () => { + it.effect("spawns the inspect probe in the configured cwd", () => { + const spawnCwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make( + inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), + ), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + return Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + expect(spawnCwds).toEqual(["/workspaces/demo"]); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 0000000000..a7c2c2ae30 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,119 @@ +/** + * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`. + * + * Unlike Claude Code, the Grok CLI reports its full skill catalog itself: + * `grok inspect --json` returns `skills[]` with `name`, `description`, + * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path` + * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats + * scanning the filesystem because the catalog honors Grok's own skill config + * (ignore lists, disabled skills) and includes plugin skills, which live + * three levels deep under `~/.grok/installed-plugins/` where a flat scan + * cannot see them. This mirrors how the Codex app-server reports skills over + * `skills/list`. Discovery is best-effort: an older CLI without `inspect`, + * a timeout, or malformed output yields an empty list, never a degraded + * provider snapshot. + * + * @module provider/Drivers/GrokSkills + */ +import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; + +/** + * Map `grok inspect --json` output onto provider skills. Entries without a + * name or a filesystem path are skipped; `userInvocable: false` skills are + * kept but disabled so pickers that filter on `enabled` hide them. + */ +export function parseGrokInspectSkills(stdout: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) { + return []; + } + const entries = (parsed as Record).skills; + if (!Array.isArray(entries)) { + return []; + } + + const skillsByName = new Map(); + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const source = + typeof record.source === "object" && record.source !== null + ? (record.source as Record) + : undefined; + const path = typeof source?.path === "string" ? source.path.trim() : ""; + if (!name || !path) { + continue; + } + const scope = typeof source?.type === "string" ? source.type.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + skillsByName.set(name, { + name, + path, + enabled: record.userInvocable !== false, + ...(scope ? { scope } : {}), + ...(description ? { description } : {}), + }); + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Run `grok inspect --json` and map the reported catalog onto provider + * skills. Never fails: any spawn error, non-zero exit, or timeout resolves + * to an empty list. + */ +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + grokSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner +> { + const command = grokSettings.binaryPath || "grok"; + const inspectResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) { + yield* Effect.logDebug("Grok skill discovery failed; continuing without skills."); + return []; + } + const output = inspectResult.success.value; + if (output.code !== 0) { + yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", { + exitCode: output.code, + }); + return []; + } + return parseGrokInspectSkills(output.stdout); +}); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f81..58ccf59120 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,6 +34,7 @@ import { import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -141,13 +142,27 @@ export const OpenCodeDriver: ProviderDriver environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); - const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: effectiveConfig.binaryPath, + directory: serverConfig.cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); const checkProvider = checkOpenCodeProviderStatus( effectiveConfig, serverConfig.cwd, processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -156,6 +171,8 @@ export const OpenCodeDriver: ProviderDriver getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, initialSnapshot: (settings) => makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 63d13b8fdc..c7fef47501 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -808,7 +808,7 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)); + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); NodeFS.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 c13b53e32d..7061b0aa6f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1307,6 +1307,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( } for (const attachment of input.attachments ?? []) { + // Claude ingests images only. Generic files reach the agent through the + // path line ProviderService puts in the prompt. if (attachment.type !== "image") { continue; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index a3ecd6ac64..c3ddfc475e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -568,6 +568,89 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("carries child model metadata through every task event", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 10)).pipe( + Effect.forkChild, + ); + + const cases = [ + ["collabAgent/started", {}], + ["collabAgent/activity", { activityKind: "started" }], + ["collabAgent/turnStarted", {}], + ["collabAgent/turnCompleted", { turn: { status: "completed" } }], + ["collabAgent/statusChanged", { status: { type: "active", activeFlags: [] } }], + ["collabAgent/tokenUsage", { tokenUsage: { total: { totalTokens: 42 } } }], + ["collabAgent/item", { item: { type: "commandExecution", command: "pwd" } }], + ["collabAgent/closed", {}], + ["collabAgent/metadataUpdated", {}], + ] as const; + + for (const [index, [method, extra]] of cases.entries()) { + yield* runtime.emit({ + id: asEventId(`evt-child-model-${index}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + agentPath: "/root/model-check", + model: " gpt-5.6-sol ", + effort: " high ", + ...extra, + }, + }); + } + yield* runtime.emit({ + id: asEventId("evt-child-model-blank"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/metadataUpdated", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + model: " ", + effort: "", + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => event.type), + [ + "task.started", + "task.started", + "task.updated", + "task.updated", + "task.updated", + "task.progress", + "task.progress", + "task.updated", + "task.updated", + "task.updated", + ], + ); + for (const event of events.slice(0, -1)) { + const payload = event.payload as Record; + NodeAssert.equal(payload.model, "gpt-5.6-sol"); + NodeAssert.equal(payload.effort, "high"); + } + + const metadataPayload = events[8]?.payload as Record; + NodeAssert.equal("status" in metadataPayload, false); + const blankMetadataPayload = events[9]?.payload as Record; + NodeAssert.equal("status" in blankMetadataPayload, false); + NodeAssert.equal("model" in blankMetadataPayload, false); + NodeAssert.equal("effort" in blankMetadataPayload, false); + }), + ); + it.effect("does not reactivate an idle child after a parent interaction", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index e2a5898a75..b8c488cb67 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -541,12 +541,16 @@ function mapCollabAgentEvent( // finding: progress rows renamed math_one to its UUID). const knownName = nickname ?? pathLeaf; const title = knownName ?? agentThreadId; + const model = typeof payload.model === "string" ? payload.model.trim() : ""; + const effort = typeof payload.effort === "string" ? payload.effort.trim() : ""; // Identity repeated on every status patch so rows are self-describing when // the start row ages out of activity retention (review finding: a // reconstructed agent had a UUID name and no role/path). - const statusLinkage = { + const linkage = { role, ...(knownName ? { title: knownName } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), ...(agentPath ? { agentPath } : {}), timelineBypass: true, } as const; @@ -561,15 +565,21 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), + ...linkage, ...(typeof payload.parentThreadId === "string" ? { parentAgentId: payload.parentThreadId } : {}), - timelineBypass: true, }, }, ]; + case "collabAgent/metadataUpdated": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, ...linkage }, + }, + ]; case "collabAgent/activity": { const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; if (activityKind === "interrupted") { @@ -577,7 +587,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; } @@ -594,9 +604,7 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), - timelineBypass: true, + ...linkage, }, }, ]; @@ -610,7 +618,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, + payload: { taskId, status: "running", ...linkage }, }, ]; case "collabAgent/turnCompleted": { @@ -630,7 +638,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status, ...statusLinkage }, + payload: { taskId, status, ...linkage }, }, ]; } @@ -646,7 +654,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "failed", ...statusLinkage }, + payload: { taskId, status: "failed", ...linkage }, }, ]; } @@ -659,7 +667,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, + payload: { taskId, status: waiting ? "waiting" : "running", ...linkage }, }, ]; } @@ -668,7 +676,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "idle", ...statusLinkage }, + payload: { taskId, status: "idle", ...linkage }, }, ]; } @@ -715,9 +723,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, typedUsage, - timelineBypass: true, }, }, ]; @@ -747,9 +754,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, summary, - timelineBypass: true, }, }, ]; @@ -759,7 +765,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; default: @@ -1863,8 +1869,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + // Codex ingests images only. Anything else would be base64-encoded as an + // image and rejected or misread; generic files reach the agent through the + // path line ProviderService puts in the prompt. const codexAttachments = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => resolveAttachment(input, attachment), { concurrency: 1 }, ); diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 5af06efb71..5bc5940fe5 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -81,10 +81,319 @@ function buildScript() { }; } +function capturedStartedActivity(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => { + const item = (entry.params as { item?: { type?: string; kind?: string } }).item; + return item?.type === "subAgentActivity" && item.kind === "started"; + }); + assert.isDefined(captured); + return { + ...captured, + params: { + ...captured.params, + item: { + ...captured.params.item, + agentThreadId: childId, + agentPath: "/root/model-check", + }, + }, + }; +} + +function capturedSpawnedThread(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started"); + assert.isDefined(captured); + return { + ...captured, + params: { + thread: { + ...captured.params.thread, + id: childId, + sessionId: childId, + parentThreadId: ROOT, + agentNickname: "model-check", + agentRole: "verifier", + source: { + subAgent: { + thread_spawn: { + agent_nickname: "model-check", + agent_path: "/root/model-check", + agent_role: "verifier", + depth: 1, + parent_thread_id: ROOT, + }, + }, + }, + }, + }, + }; +} + +function childSettings(threadId: string, model: string, effort: string) { + return { + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + collaborationMode: { mode: "default", settings: { model } }, + cwd: "/workspace/repo", + effort, + model, + modelProvider: "openai", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + }; +} + +function readRecordedRequests() { + return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method: string; params: Record }); +} + const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); describe("CodexSessionRuntime collab integration", () => { + it.effect("looks up child model metadata once after activity registration", () => + Effect.gen(function* () { + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + capturedStartedActivity(), + capturedStartedActivity(), + { + ...capturedStartedActivity(CHILD_B), + params: { + ...capturedStartedActivity(CHILD_B).params, + item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" }, + }, + }, + { method: "thread/closed", params: { threadId: CHILD_B } }, + capturedSpawnedThread(ROOT), + ], + childResumeSnapshots: { + [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-activity"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const metadataFiber = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "collabAgent/metadataUpdated" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + const session = yield* runtime.start(); + assert.equal(session.model, "gpt-5.6-sol"); + yield* runtime.sendTurn({ input: "start one child" }); + const metadataEvents = Array.from(yield* Fiber.join(metadataFiber)); + assert.deepInclude(metadataEvents[0]?.payload, { + agentThreadId: CHILD_A, + model: "gpt-5.6-luna", + effort: "low", + }); + assert.deepEqual(readRecordedRequests(), [ + { + method: "thread/resume", + params: { threadId: CHILD_A, excludeTurns: true }, + }, + ]); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps child settings and reroutes newer than the resume snapshot", () => + Effect.gen(function* () { + const statusChanged = wireFixture.notifications.find( + (entry) => + entry.method === "thread/status/changed" && + (entry.params as { threadId?: string }).threadId === CHILD_A, + ); + assert.isDefined(statusChanged); + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + childSettings(CHILD_A, "child-before", "medium"), + capturedSpawnedThread(), + childSettings(CHILD_A, "child-after", "high"), + { + method: "model/rerouted", + params: { + threadId: CHILD_A, + turnId: `${CHILD_A}-turn`, + fromModel: "child-after", + toModel: "child-rerouted", + reason: "highRiskCyberActivity", + }, + }, + { + method: "model/rerouted", + params: { + threadId: ROOT, + turnId: `${ROOT}-turn`, + fromModel: "gpt-5.6-sol", + toModel: "root-rerouted", + reason: "highRiskCyberActivity", + }, + }, + ], + childResumeSnapshots: { + [CHILD_A]: { + model: "stale-snapshot", + reasoningEffort: "low", + notifications: [statusChanged], + }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-spawn"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "collabAgent/statusChanged" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "start one spawned child" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find((event) => event.method === "collabAgent/started"); + assert.deepInclude(started?.payload, { + agentThreadId: CHILD_A, + model: "child-before", + effort: "medium", + }); + const childStatus = events.find((event) => event.method === "collabAgent/statusChanged"); + assert.deepInclude(childStatus?.payload, { + agentThreadId: CHILD_A, + model: "child-rerouted", + effort: "high", + }); + assert.isTrue( + events.some( + (event) => + event.method === "model/rerouted" && + (event.payload as { threadId?: string }).threadId === ROOT, + ), + "the root reroute must stay on the parent path", + ); + assert.isFalse( + events.some( + (event) => + (event.method === "thread/settings/updated" || event.method === "model/rerouted") && + (event.payload as { threadId?: string }).threadId === CHILD_A, + ), + "child metadata notifications must not leak to the parent path", + ); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not delay the parent turn when the child lookup fails", () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + for (const [name, childSnapshot] of [ + ["hang", { hang: true }], + ["error", { error: "child unavailable" }], + ] as const) { + yield* Effect.gen(function* () { + const marker = `lookup-${name}`; + const script = { + rootThreadId: ROOT, + recordRequests: true, + resumeRequestMarker: marker, + notifications: [capturedStartedActivity()], + childResumeSnapshots: { [CHILD_A]: childSnapshot }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make(`thread-collab-model-${name}`), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "serverRequest/resolved" && + (event.payload as { requestId?: string }).requestId === marker, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "finish without child metadata" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.isTrue(events.some((event) => event.method === "turn/completed")); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }).pipe(Effect.scoped); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => Effect.gen(function* () { // @effect-diagnostics-next-line preferSchemaOverJson:off diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts index 50e5e819d1..363c1560ca 100644 --- a/apps/server/src/provider/Layers/CodexCollabWire.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts @@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "turn/plan/updated", "item/plan/delta", + "thread/settings/updated", + "model/rerouted", ]) { assert.notEqual( routeCodexChildNotification(method), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 52a8fdd25d..776a8e00e1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -86,13 +86,18 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun return "ChatGPT Pro 5x Subscription"; case "team": return "ChatGPT Team Subscription"; + case "self_serve_business_prolite": case "self_serve_business_usage_based": case "business": return "ChatGPT Business Subscription"; + case "ent26": + case "enterprise_cbp_automation": case "enterprise_cbp_usage_based": case "enterprise": return "ChatGPT Enterprise Subscription"; case "edu": + case "edu_plus": + case "edu_pro": return "ChatGPT Edu Subscription"; case "unknown": return "ChatGPT Subscription"; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index bba888ee3a..85f44065ed 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -146,6 +146,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( CodexTurnStartParamsWithCollaborationMode, ); +const CodexChildResumeMetadata = Schema.Struct({ + thread: Schema.Struct({ id: Schema.String }), + model: Schema.String, + reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; @@ -743,8 +749,10 @@ function readNotificationThreadId(notification: CodexServerNotification): string case "thread/unarchived": case "thread/closed": case "thread/name/updated": + case "thread/settings/updated": case "thread/tokenUsage/updated": case "thread/goal/cleared": + case "model/rerouted": case "turn/started": case "hook/started": case "turn/completed": @@ -919,6 +927,35 @@ interface CollabChildAgentState { readonly spawnTurnId: TurnId | undefined; } +interface CollabChildMetadataState { + readonly model: string | undefined; + readonly effort: string | undefined; + readonly lookupStarted: boolean; + readonly closed: boolean; +} + +function collabChildIdentity( + child: CollabChildAgentState, + metadata: CollabChildMetadataState | undefined, +) { + return { + agentThreadId: child.agentThreadId, + ...(child.nickname ? { nickname: child.nickname } : {}), + ...(child.role ? { role: child.role } : {}), + ...(child.agentPath ? { agentPath: child.agentPath } : {}), + ...(metadata?.model ? { model: metadata.model } : {}), + ...(metadata?.effort ? { effort: metadata.effort } : {}), + }; +} + +function nonEmptyMetadataValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function readThreadSpawnSource(thread: { readonly source: unknown }): | { nickname: string | undefined; @@ -984,9 +1021,11 @@ function shouldSuppressChildConversationNotification( method === "thread/closed" || method === "thread/compacted" || method === "thread/name/updated" || + method === "thread/settings/updated" || method === "thread/tokenUsage/updated" || method === "thread/goal/updated" || method === "thread/goal/cleared" || + method === "model/rerouted" || method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated" || @@ -1017,6 +1056,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -1035,7 +1076,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ "turn/plan/updated", "turn/diff/updated", "thread/name/updated", - "thread/settings/updated", "rawResponseItem/completed", // Child-owned thread lifecycle: the parent adapter maps these onto the // PARENT thread (archived/compacted state), so a child compacting would @@ -1143,6 +1183,7 @@ export const makeCodexSessionRuntime = ( const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); const collabChildAgentsRef = yield* Ref.make(new Map()); + const collabChildMetadataRef = yield* Ref.make(new Map()); /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); @@ -1238,6 +1279,133 @@ export const makeCodexSessionRuntime = ( message, }); + const updateCollabChildMetadata = ( + agentThreadId: string, + update: { readonly model?: string; readonly effort?: string }, + overwriteKnown: boolean, + ) => + Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + const model = + update.model && (overwriteKnown || !previous.model) ? update.model : previous.model; + const effort = + update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort; + const changed = model !== previous.model || effort !== previous.effort; + if (!changed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, model, effort }); + return [true, next] as const; + }); + + const markCollabChildClosed = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: true }); + return next; + }); + + const markCollabChildOpen = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId); + if (!previous?.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: false }); + return next; + }); + + const emitCollabChildMetadataUpdated = Effect.fn( + "CodexSessionRuntime.emitCollabChildMetadataUpdated", + )(function* (agentThreadId: string) { + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/metadataUpdated", + payload: collabChildIdentity(child, metadata), + }); + }); + + const startCollabChildMetadataLookup = Effect.fn( + "CodexSessionRuntime.startCollabChildMetadataLookup", + )(function* (agentThreadId: string) { + const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.lookupStarted || previous.closed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, lookupStarted: true }); + return [true, next] as const; + }); + if (!shouldStart) { + return; + } + + // The child is already loaded. This rejoins it without starting a turn, + // and excludeTurns avoids loading or replaying its history. + yield* client.raw + .request("thread/resume", { threadId: agentThreadId, excludeTurns: true }) + .pipe( + Effect.flatMap(decodeCodexChildResumeMetadata), + Effect.timeout("5 seconds"), + Effect.flatMap((response) => + Effect.gen(function* () { + if (response.thread.id !== agentThreadId) { + return; + } + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + const model = nonEmptyMetadataValue(response.model); + const effort = nonEmptyMetadataValue(response.reasoningEffort); + const changed = yield* updateCollabChildMetadata( + agentThreadId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + false, + ); + if (changed) { + yield* emitCollabChildMetadataUpdated(agentThreadId); + } + }), + ), + Effect.catch(() => Effect.void), + Effect.forkIn(runtimeScope), + ); + }); + const settlePendingApprovals = (decision: ProviderApprovalDecision) => Ref.get(pendingApprovalsRef).pipe( Effect.flatMap((pendingApprovals) => @@ -1278,6 +1446,10 @@ export const makeCodexSessionRuntime = ( if (!spawn) { return false; } + const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (thread.id === rootProviderThreadId) { + return false; + } // Merge with any subAgentActivity registration that got here // first. spawnTurnId is REGISTRATION-time-only on both paths: for // an already-known child we keep its value (set or unset) — a @@ -1304,20 +1476,19 @@ export const makeCodexSessionRuntime = ( next.set(thread.id, state); return next; }); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/started", ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), payload: { - agentThreadId: state.agentThreadId, - ...(state.nickname ? { nickname: state.nickname } : {}), - ...(state.role ? { role: state.role } : {}), - ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...collabChildIdentity(state, metadata), ...(state.depth !== undefined ? { depth: state.depth } : {}), ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), }, }); + yield* startCollabChildMetadataLookup(thread.id); return true; } @@ -1367,17 +1538,22 @@ export const makeCodexSessionRuntime = ( return next; }); const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/activity", ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), payload: { - agentThreadId: item.agentThreadId, - agentPath: item.agentPath, + ...(registeredChild + ? collabChildIdentity(registeredChild, metadata) + : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }), activityKind: item.kind, }, }); + if (item.kind === "started") { + yield* startCollabChildMetadataLookup(item.agentThreadId); + } return true; } @@ -1393,19 +1569,45 @@ export const makeCodexSessionRuntime = ( if (providerConversationId === interceptRootId) { return false; } + + if ( + interceptRootId !== undefined && + (notification.method === "thread/settings/updated" || + notification.method === "model/rerouted") + ) { + const model = nonEmptyMetadataValue( + notification.method === "thread/settings/updated" + ? notification.params.threadSettings.model + : notification.params.toModel, + ); + const effort = + notification.method === "thread/settings/updated" + ? nonEmptyMetadataValue(notification.params.threadSettings.effort) + : undefined; + const changed = yield* updateCollabChildMetadata( + providerConversationId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + true, + ); + if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) { + yield* emitCollabChildMetadataUpdated(providerConversationId); + } + return true; + } + const children = yield* Ref.get(collabChildAgentsRef); const child = children.get(providerConversationId); if (!child) { return false; } - const childIdentity = { - agentThreadId: child.agentThreadId, - ...(child.nickname ? { nickname: child.nickname } : {}), - ...(child.role ? { role: child.role } : {}), - ...(child.agentPath ? { agentPath: child.agentPath } : {}), - }; + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId); + const childIdentity = collabChildIdentity(child, metadata); switch (notification.method) { case "turn/started": { + yield* markCollabChildOpen(child.agentThreadId); const childTurnId = typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" ? ((notification.params as { turn: { id: string } }).turn.id as string) @@ -1489,6 +1691,7 @@ export const makeCodexSessionRuntime = ( next.delete(child.agentThreadId); return next; }); + yield* markCollabChildClosed(child.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5878d85f64..e7914061b1 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -977,6 +977,11 @@ export function makeCursorAdapter( } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { + // Cursor ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6cb71660a7..eeee17d9ac 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,13 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { + grokPromptSettlementBelongsToContext, + isGrokEnterPlanModeToolCall, + makeGrokAdapter, + nextGrokPlanModeActive, + selectGrokPermissionOptionId, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); +it("detects enter_plan_mode tool calls from title and rawInput", () => { + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }), + ); + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "Plan mode entered", + data: { toolCallId: "1", rawInput: { variant: "EnterPlanMode" } }, + }), + ); + assert.isFalse( + isGrokEnterPlanModeToolCall({ + title: "write", + data: { toolCallId: "1", rawInput: { file_path: "/tmp/x", content: "y" } }, + }), + ); +}); + +it("only sets planModeActive after a successful enter_plan_mode", () => { + const enter = { + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }; + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "pending" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "inProgress" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "completed" })); + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "failed" })); + assert.isFalse(nextGrokPlanModeActive(true, { ...enter, status: "failed" })); + assert.isTrue( + nextGrokPlanModeActive(true, { + title: "write", + status: "completed", + data: { toolCallId: "2" }, + }), + ); +}); + +function grokPermissionRequest( + options: ReadonlyArray<{ + readonly optionId: string; + readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + }>, +) { + return { + sessionId: "mock-session-1", + toolCall: { + toolCallId: "tool-call-1", + title: "cat package.json", + kind: "execute" as const, + status: "pending" as const, + }, + options: options.map((option) => ({ + optionId: option.optionId, + name: option.kind, + kind: option.kind, + })), + }; +} + +it("maps Always allow to allow_once when Grok omits allow_always", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "decline"), "reject-once"); +}); + +it("prefers allow_always when Grok offers it", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-always"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); +}); + it("requires a settlement to match the live Grok turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); @@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("does not time out a Grok turn before ACP emits progress", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-silent-turn"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "turn.started") { + yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "silence forever", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnStarted); + + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "keep reasoning", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(steerSendTurnFiber); + + assert.equal(completed.payload.state, "cancelled"); + const session = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails a Grok turn that stalls after ACP content begins", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-content-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + assert.equal( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ).length, + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when a turn is steered", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-steer"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then steer", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "continue working", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* Fiber.interrupt(steerSendTurnFiber); + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(firstSendTurnFiber); + assert.equal(completed.payload.state, "cancelled"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when ACP updates its plan", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-plan-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_PLAN_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const planUpdated = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.plan.updated") { + yield* Deferred.succeed(planUpdated, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "update plan then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(planUpdated).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("1 second"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles a stalled Grok turn after the active-tool deadline", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-active-tool"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + activeToolInactivityTimeoutMs: 5_000, + }); + const activeTool = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "item.updated") { + yield* Deferred.succeed(activeTool, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "run a long tool", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(activeTool).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("4999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + assert.equal( + (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId) + ?.status, + "running", + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + assert.equal(completed.payload.state, "failed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); @@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("surfaces Grok usage limits without clearing the selected model", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-usage-limit-error"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "hit the usage limit", + attachments: [], + }), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const terminalEvents = runtimeEvents.filter( + (event) => event.type === "turn.completed" && event.threadId === threadId, + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.include(error.message, "Grok usage limit reached. Try again later."); + assert.equal(readySession?.status, "ready"); + assert.equal(readySession?.model, "grok-build"); + assert.isUndefined(readySession?.activeTurnId); + assert.lengthOf(terminalEvents, 1); + const [terminalEvent] = terminalEvents; + assert.equal(terminalEvent?.type, "turn.completed"); + if (terminalEvent?.type === "turn.completed") { + assert.equal(terminalEvent.payload.state, "failed"); + assert.include( + terminalEvent.payload.errorMessage ?? "", + "Grok usage limit reached. Try again later.", + ); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores replayed session/load updates when resuming a Grok session", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-load-replay-filter"); @@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("captures xAI exit_plan_mode as a proposed plan and unblocks the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-mode"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "present the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal(proposedEvent.type, "turn.proposed.completed"); + assert.equal(proposedEvent.payload.planMarkdown, "# Exit plan\n\n- Step one\n- Step two"); + assert.equal(proposedEvent.raw?.method, "_x.ai/exit_plan_mode"); + yield* Deferred.await(turnCompleted); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces plan.md writes as a proposed plan while plan mode is active", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-plan-md-write"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal( + proposedEvent.payload.planMarkdown, + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ); + assert.equal(proposedEvent.raw?.method, "session/update"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a Grok turn running when Always allow has no allow_always option", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-always-allow-without-allow-always"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + yield* Ref.update(openedCount, (count) => count + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "acceptForSession", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "approve this session", + attachments: [], + }); + + assert.equal(yield* Ref.get(openedCount), 1); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResults = requests.filter( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome, + ); + assert.equal(permissionResults.length, 2); + assert.isTrue( + permissionResults.every( + (entry) => + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-once", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("asks before a different command after Always allow this session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-approval-scope"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_PERMISSION_TITLE: "Terminal", + T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + count === 1 ? "acceptForSession" : "decline", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] }); + assert.equal(yield* Ref.get(openedCount), 2); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("captures a plan under the provider instance GROK_HOME", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-instance-plan-home"); + const grokHome = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-instance-home-")), + ); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1", + T3_ACP_PLAN_ROOT: grokHome, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + environment: { ...process.env, GROK_HOME: grokHome }, + }); + const plans = yield* Ref.make>([]); + const completed = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "turn.proposed.completed") { + return Ref.update(plans, (current) => [...current, event.payload.planMarkdown]); + } + return event.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid) + : Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + yield* Deferred.await(completed); + assert.deepEqual(yield* Ref.get(plans), [ + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ]); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("handles xAI ask_user_question extension requests", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-xai-ask-user-question"); @@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("settles a stalled Grok turn after its first activity is user input", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-ask-user-question"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + const completed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(completed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "ask before continuing", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.payload.questions.length, 1); + assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?"); + assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?"); + assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question"); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + "Which scope should Grok use?": "Workspace", + }, + ); + + const resolvedEvent = yield* Deferred.await(resolved); + assert.deepEqual(resolvedEvent.payload.answers, { + "Which scope should Grok use?": "Workspace", + }); + assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); + + yield* TestClock.adjust("1 second"); + const completedEvent = yield* Deferred.await(completed).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + assert.equal(completedEvent.payload.state, "failed"); + yield* Fiber.join(sendTurnFiber); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 1004339f3e..627a572b3f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -12,9 +12,14 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, + normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; @@ -74,6 +86,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; +const NANOS_PER_MILLI = 1_000_000n; +// ACP does not expose Grok's private `streaming_reasoning` phase. Once it has +// emitted standard ACP progress, ten silent minutes is long enough to avoid +// treating legitimate reasoning as a stalled stream. +const DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000; +// A tool can legitimately run without emitting text for much longer than +// reasoning. It still needs a deadline so a lost tool update cannot leave the +// turn working forever. +const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); @@ -85,6 +106,10 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + /** Override the conservative ACP turn liveness timeout in focused tests. */ + readonly turnInactivityTimeoutMs?: number; + /** Override the longer active-tool liveness timeout in focused tests. */ + readonly activeToolInactivityTimeoutMs?: number; } interface PendingApproval { @@ -99,6 +124,10 @@ interface PendingUserInput { readonly resolution: Deferred.Deferred; } +interface GrokTurnLivenessSignal { + readonly turnId: TurnId; +} + interface GrokSessionContext { readonly threadId: ThreadId; readonly acpSessionId: string; @@ -110,6 +139,14 @@ interface GrokSessionContext { readonly pendingUserInputs: Map; turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; + /** + * Latest plan.md body + turn it was emitted for. Dedupe is turn-scoped so a + * later turn re-proposing the same text still gets a new proposed-plan card. + */ + lastKnownProposedPlanMarkdown: string | undefined; + lastKnownProposedPlanTurnId: TurnId | undefined; + /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ + planModeActive: boolean; activeTurnId: TurnId | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; @@ -117,7 +154,15 @@ interface GrokSessionContext { * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; + readonly livenessSignals: Queue.Queue; + livenessTurnId: TurnId | undefined; + lastTurnActivityAtNanos: bigint | undefined; + readonly activeToolCallIds: Set; + livenessUpdatesInFlight: number; + /** Prompt RPCs that returned before their turn settlement acquired the lock. */ + promptResponsesReady: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; stopped: boolean; } @@ -165,6 +210,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; +function clearProposedPlanFallback(ctx: GrokSessionContext): void { + ctx.lastKnownProposedPlanMarkdown = undefined; + ctx.lastKnownProposedPlanTurnId = undefined; + ctx.planModeActive = false; +} + +/** Detect Grok's enter_plan_mode tool call from ACP tool state. */ +export function isGrokEnterPlanModeToolCall(toolCall: { + readonly title?: string; + readonly data: Record; +}): boolean { + const title = toolCall.title?.trim().toLowerCase() ?? ""; + if ( + title === "enter_plan_mode" || + title === "plan: enter" || + title === "plan mode entered" || + title.includes("enter_plan_mode") + ) { + return true; + } + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") { + return true; + } + return false; +} + +/** Failed enter_plan_mode must not leave planModeActive stuck on. */ +export function nextGrokPlanModeActive( + currentlyActive: boolean, + toolCall: { + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly data: Record; + }, +): boolean { + if (!isGrokEnterPlanModeToolCall(toolCall)) { + return currentlyActive; + } + if (toolCall.status === "failed") { + return false; + } + if (toolCall.status === "completed" || toolCall.status === "inProgress") { + return true; + } + return currentlyActive; +} + const resolveSessionCallbackTurnId = ( sessions: ReadonlyMap, threadId: ThreadId, @@ -180,26 +273,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function selectPermissionOptionId( +export function selectGrokPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { - const kind = + const preferredKind = decision === "acceptForSession" ? "allow_always" : decision === "accept" ? "allow_once" : "reject_once"; - const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() || undefined; + const preferred = request.options.find((entry) => entry.kind === preferredKind); + const preferredId = preferred?.optionId.trim(); + if (preferredId) { + return preferredId; + } + // Grok 4.6 often omits allow_always. T3 still offers "Always allow this session". + if (decision === "acceptForSession") { + const once = request.options.find((entry) => entry.kind === "allow_once"); + const onceId = once?.optionId.trim(); + if (onceId) { + return onceId; + } + } + return undefined; } function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") + selectGrokPermissionOptionId(request, "acceptForSession") ?? + selectGrokPermissionOptionId(request, "accept") ); } @@ -241,10 +346,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const grokPlanPathHost = { + platform: hostPlatform, + environment: options?.environment ?? hostEnvironment, + }; const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); + const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs; + const turnInactivityTimeoutMs = + typeof requestedTurnInactivityTimeoutMs === "number" && + Number.isFinite(requestedTurnInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs)) + : DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS; + const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI; + const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs; + const activeToolInactivityTimeoutMs = + typeof requestedActiveToolInactivityTimeoutMs === "number" && + Number.isFinite(requestedActiveToolInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs)) + : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS; + const activeToolInactivityTimeoutNanos = + BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( @@ -295,6 +421,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const signalTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid); + + const beginTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Effect.sync(() => { + ctx.livenessTurnId = turnId; + // Do not start a deadline until ACP has made observable progress. + // Grok's private reasoning phase is not present in the ACP stream. + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + }); + + const clearTurnLiveness = (ctx: GrokSessionContext) => { + const turnId = ctx.livenessTurnId; + ctx.livenessTurnId = undefined; + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + ctx.livenessUpdatesInFlight = 0; + ctx.promptResponsesReady = 0; + return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId); + }; + + const recordTurnActivity = Effect.fn("GrokAdapter.recordTurnActivity")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + event: Extract< + AcpSessionRuntime.AcpSessionRuntimeEvent, + { + _tag: + | "AssistantItemStarted" + | "AssistantItemCompleted" + | "PlanUpdated" + | "ToolCallUpdated" + | "ContentDelta"; + } + >, + ) { + if ( + ctx.livenessTurnId !== turnId || + (event._tag === "ContentDelta" && event.text.length === 0) + ) { + return; + } + ctx.livenessUpdatesInFlight += 1; + try { + const activityAtNanos = yield* Clock.monotonicTimeNanos; + if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) { + return; + } + if (event._tag === "ToolCallUpdated") { + if (event.toolCall.status === "completed" || event.toolCall.status === "failed") { + ctx.activeToolCallIds.delete(event.toolCall.toolCallId); + } else { + // A tool update without a terminal status receives a longer + // deadline so a long-running tool is not mistaken for a stall. + ctx.activeToolCallIds.add(event.toolCall.toolCallId); + } + } + ctx.lastTurnActivityAtNanos = activityAtNanos; + } finally { + // Decrement before signaling. The watchdog treats in-flight updates as a + // pause; if it consumed a signal while the counter was still > 0 it would + // wait on the next take with no follow-up wake after this decrement. + ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1); + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const hasLivenessPause = (ctx: GrokSessionContext) => + ctx.pendingApprovals.size > 0 || + ctx.pendingUserInputs.size > 0 || + ctx.livenessUpdatesInFlight > 0; + + const livenessTimeoutFor = (ctx: GrokSessionContext) => + ctx.activeToolCallIds.size > 0 + ? { + milliseconds: activeToolInactivityTimeoutMs, + nanos: activeToolInactivityTimeoutNanos, + } + : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos }; + + const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => { + const ctx = sessions.get(threadId); + return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void; + }; + + const resumeSessionTurnLiveness = Effect.fn("GrokAdapter.resumeSessionTurnLiveness")(function* ( + threadId: ThreadId, + turnId: TurnId | undefined, + ) { + const ctx = sessions.get(threadId); + if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) { + return; + } + // An approval or user-input wait can last longer than the watchdog. + // Its resolution gives the provider a fresh window to resume output. + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }); + + const refreshSessionTurnLiveness = Effect.fn("GrokAdapter.refreshSessionTurnLiveness")( + function* (threadId: ThreadId, turnId: TurnId | undefined) { + const ctx = sessions.get(threadId); + if ( + !ctx || + turnId === undefined || + ctx.livenessTurnId !== turnId || + ctx.lastTurnActivityAtNanos === undefined + ) { + return; + } + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }, + ); + + const markPromptResponseReady = Effect.fn("GrokAdapter.markPromptResponseReady")(function* ( + threadId: ThreadId, + acpSessionId: string, + turnId: TurnId, + ) { + const ctx = sessions.get(threadId); + if ( + ctx && + ctx.acpSessionId === acpSessionId && + !ctx.stopped && + !ctx.interruptedTurnIds.has(turnId) && + ctx.livenessTurnId === turnId && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId + ) { + ctx.promptResponsesReady += 1; + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const consumePromptResponseReady = (ctx: GrokSessionContext) => { + ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1); + }; + const settlePromptInFlight = ( threadId: ThreadId, turnId: TurnId, @@ -374,6 +640,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt, }; } + yield* clearTurnLiveness(liveCtx); return; } settleTurnId = fallbackTurnId; @@ -390,6 +657,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } liveCtx.promptsInFlight = remainingPrompts; } + yield* clearTurnLiveness(liveCtx); const updatedAt = yield* nowIso; const canEmitTurnCompletion = liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; @@ -398,6 +666,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte options?.completedStopReason !== undefined && canEmitTurnCompletion; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; liveCtx.activeTurnId = undefined; + // Drop turn-scoped plan fallback so a later empty exit_plan cannot + // resurrect this turn's markdown as a fresh proposal. + clearProposedPlanFallback(liveCtx); liveCtx.session = { ...readySession, status: "ready", @@ -433,6 +704,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } }); + const isLiveTurn = (ctx: GrokSessionContext, turnId: TurnId) => + ctx.promptsInFlight > 0 && + ctx.promptsInFlight > ctx.promptResponsesReady && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId && + (ctx.session.status === "running" || ctx.session.status === "connecting"); + + const settleStalledTurn = Effect.fn("GrokAdapter.settleStalledTurn")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + ) { + return yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if ( + liveCtx !== ctx || + ctx.stopped || + !isLiveTurn(ctx, turnId) || + ctx.interruptedTurnIds.has(turnId) || + hasLivenessPause(ctx) + ) { + return; + } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + return; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + if ( + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) || + nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos + ) { + return; + } + + // Mark before cancel/drain so notifications already in flight finish + // before the terminal event, while late notifications are dropped. + ctx.interruptedTurnIds.add(turnId); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error), + ), + ), + ); + yield* Effect.ignore(ctx.acp.drainEvents); + yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, { + errorMessage: `Grok ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`, + settleAllPrompts: true, + }); + }), + ); + }); + + const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")( + function* (ctx: GrokSessionContext) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } + + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); + } + } + }, + Effect.catch(() => Effect.void), + ); + const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -496,6 +865,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); + /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */ + const emitProposedPlanCompleted = ( + ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + planMarkdown: string, + raw: { readonly method: string; readonly payload: unknown }, + ) => + Effect.gen(function* () { + const trimmed = planMarkdown.trim(); + if (trimmed.length === 0) { + ctx.lastKnownProposedPlanMarkdown = ""; + ctx.lastKnownProposedPlanTurnId = turnId; + return; + } + // Turn-scoped dedupe: identical text on a later turn must still emit. + if ( + ctx.lastKnownProposedPlanMarkdown === trimmed && + ctx.lastKnownProposedPlanTurnId === turnId + ) { + return; + } + ctx.lastKnownProposedPlanMarkdown = trimmed; + ctx.lastKnownProposedPlanTurnId = turnId; + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { planMarkdown: trimmed }, + raw: { + source: "acp.grok.extension", + method: raw.method, + payload: raw.payload, + }, + }); + }); + const requireSession = ( threadId: ThreadId, ): Effect.Effect => { @@ -557,6 +965,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const pendingApprovals = new Map(); const pendingUserInputs = new Map(); + const sessionApprovedOperations = new Set(); const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -576,6 +985,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte environment: mergeProviderSessionEnvironment(options?.environment, input.env), childProcessSpawner, cwd, + runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession @@ -622,6 +1032,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const resolution = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingUserInputs.set(requestId, { resolution }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), @@ -638,6 +1049,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const resolved = yield* Deferred.await(resolution); pendingUserInputs.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; yield* offerRuntimeEvent({ type: "user-input.resolved", @@ -664,12 +1076,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Grok intercepts exit_plan_mode and reverse-requests client approval. + // Capture plan into T3 proposed-plan UI and abandon the native gate so + // the turn does not hang (Claude ExitPlanMode pattern). + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + const ctx = sessions.get(input.threadId); + const planMarkdown = extractXAiExitPlanMarkdown( + params, + ctx?.lastKnownProposedPlanMarkdown, + ); + if (ctx) { + yield* emitProposedPlanCompleted( + ctx, + turnId, + yield* makeEventStamp(), + planMarkdown, + { method, payload: params }, + ); + ctx.planModeActive = false; + } else { + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + return makeXAiExitPlanModeCapturedResponse(); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + const permissionRequest = parsePermissionRequest(params); + const command = permissionRequest.toolCall?.command; + const { kind, title, rawInput, locations } = params.toolCall; + let operationInput = rawInput; + if (isRecord(rawInput) && rawInput.variant === "Bash") { + const { description: _description, ...shellInput } = rawInput; + operationInput = shellInput; + } + // Remember the operation, not the tool-call id or every future tool. + // Generic titles without input cannot identify an operation safely. + const approvalKey = + command || (isRecord(rawInput) && Object.keys(rawInput).length > 0) + ? stableStringify({ kind, title, command, input: operationInput, locations }) + : undefined; + const alreadyApproved = + approvalKey !== undefined && sessionApprovedOperations.has(approvalKey); + if (input.runtimeMode === "full-access" || alreadyApproved) { + const autoApprovedOptionId = + input.runtimeMode === "full-access" + ? selectAutoApprovedPermissionOption(params) + : selectGrokPermissionOptionId(params, "accept"); if (autoApprovedOptionId !== undefined) { return { outcome: { @@ -679,12 +1156,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }; } } - const permissionRequest = parsePermissionRequest(params); const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); const decision = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingApprovals.set(requestId, { decision }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), @@ -705,6 +1182,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const resolved = yield* Deferred.await(decision); pendingApprovals.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), @@ -717,7 +1195,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + resolved === "cancel" + ? undefined + : selectGrokPermissionOptionId(params, resolved); + if ( + resolved === "acceptForSession" && + selectedOptionId && + approvalKey !== undefined + ) { + sessionApprovedOperations.add(approvalKey); + } return { outcome: selectedOptionId ? { @@ -739,10 +1226,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedStartModelId = grokModelSelection?.model ? resolveGrokAcpBaseModelId(grokModelSelection.model) : undefined; + const currentStartModelId = currentGrokModelIdFromSessionSetup( + started.sessionSetupResult, + ); + const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ); + const requestedStartReasoningEffort = getModelSelectionStringOptionValue( + grokModelSelection, + "reasoningEffort", + ); const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: currentStartModelId, + currentReasoningEffort: currentStartReasoningEffort, requestedModelId: requestedStartModelId, + requestedReasoningEffort: requestedStartReasoningEffort, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -775,10 +1274,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte pendingUserInputs, turns: [], lastPlanFingerprint: undefined, + lastKnownProposedPlanMarkdown: undefined, + lastKnownProposedPlanTurnId: undefined, + planModeActive: false, activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, + livenessSignals: yield* Queue.sliding(1), + livenessTurnId: undefined, + lastTurnActivityAtNanos: undefined, + activeToolCallIds: new Set(), + livenessUpdatesInFlight: 0, + promptResponsesReady: 0, currentModelId: boundModelId, + currentReasoningEffort: + requestedStartReasoningEffort !== undefined + ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) + : currentStartReasoningEffort, stopped: false, }; @@ -808,6 +1320,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ) { return; } + if ( + event._tag === "AssistantItemStarted" || + event._tag === "AssistantItemCompleted" || + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* recordTurnActivity(ctx, notificationTurnId, event); + } const stamp = yield* makeEventStamp(); switch (event._tag) { @@ -848,7 +1369,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -859,7 +1380,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte rawPayload: event.rawPayload, }), ); + ctx.planModeActive = nextGrokPlanModeActive(ctx.planModeActive, event.toolCall); + // Only promote session plan.md writes while plan mode is + // active — avoids treating unrelated plan files as proposals. + // Fresh stamp: must not share eventId with the tool lifecycle event. + if (ctx.planModeActive) { + const planMarkdown = extractGrokPlanMarkdownFromToolCallData( + event.toolCall.data, + grokPlanPathHost, + ); + if (planMarkdown !== undefined) { + yield* emitProposedPlanCompleted( + ctx, + notificationTurnId, + yield* makeEventStamp(), + planMarkdown, + { + method: "session/update", + payload: event.rawPayload, + }, + ); + } + } return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -892,6 +1436,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); + yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid); sessionScopeTransferred = true; yield* offerRuntimeEvent({ @@ -938,6 +1483,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // New turn: do not fall back to a previous turn's plan.md body when + // exit_plan_mode omits planContent. + if (steeringTurnId === undefined) { + clearProposedPlanFallback(ctx); + } ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -953,17 +1503,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ - runtime: ctx.acp, - currentModelId: ctx.currentModelId, - requestedModelId: requestedTurnModelId, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); + const requestedTurnReasoningEffort = getModelSelectionStringOptionValue( + turnModelSelection, + "reasoningEffort", + ); const text = input.input?.trim(); + // Grok ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ @@ -1008,7 +1557,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedModelId: requestedTurnModelId, + requestedReasoningEffort: requestedTurnReasoningEffort, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); ctx.currentModelId = currentModelId; + if (requestedTurnReasoningEffort !== undefined) { + ctx.currentReasoningEffort = normalizeGrokReasoningEffort( + requestedTurnReasoningEffort, + ); + } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; @@ -1037,6 +1600,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: yield* nowIso, ...(displayModel ? { model: displayModel } : {}), }; + if (steeringTurnId === undefined) { + yield* beginTurnLiveness(ctx, turnId); + } else { + yield* refreshSessionTurnLiveness(input.threadId, turnId); + } if (steeringTurnId === undefined) { yield* offerRuntimeEvent({ @@ -1087,10 +1655,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }) .pipe( Effect.tap((promptResult) => - Effect.all([ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - ]), + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), ), Effect.tapError((error) => Ref.set( @@ -1131,6 +1703,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* Effect.yieldNow; } yield* prepared.acp.drainEvents; + consumePromptResponseReady(ctx); if (ctx.interruptedTurnIds.has(prepared.turnId)) { yield* Ref.set(promptSettled, true); return { @@ -1189,6 +1762,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: completedAt, ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; + yield* clearTurnLiveness(ctx); const completedStopReason = completedStopReasonFromPromptResponse(result); yield* offerRuntimeEvent({ type: "turn.completed", @@ -1245,6 +1819,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (ctx.interruptedTurnIds.has(prepared.turnId)) { return; } + consumePromptResponseReady(ctx); if ( ctx.promptsInFlight <= 0 || ctx.activeTurnId !== prepared.turnId || @@ -1361,6 +1936,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte status: "ready", updatedAt, }; + yield* clearTurnLiveness(ctx); } }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26d..42d642a3e7 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,183 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildGrokModelCapabilities, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +describe("buildGrokModelCapabilities", () => { + it("preserves ACP-provided reasoning labels and the active default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + { value: "low", label: "Low Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + { id: "low", label: "Low Effort" }, + ], + }, + ]); + }); + + it("uses raw ACP values when option labels are omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "xhigh" }, + { id: "medium", label: "medium" }, + ], + }, + ]); + }); + + it("keeps ACP current effort separate from its collapsed advertised default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "medium", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + ], + }, + ]); + }); + + it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "not a token", + label: "High Effort", + description: "Higher implementation quality", + default: true, + }, + { id: "bad id", value: "also invalid", label: "Invalid" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { + id: "high", + label: "High Effort", + description: "Higher implementation quality", + isDefault: true, + }, + ], + }, + ]); + }); + + it("accepts an advertised ACP menu when the support flag is omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toHaveLength(1); + }); + + it("honors an explicit ACP opt-out even when a menu is present", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: false, + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([]); + }); + + it("does not synthesize a reasoning menu when ACP omits it", () => { + expect( + buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, + }).optionDescriptors, + ).toEqual([]); + }); + + it("keeps non-reasoning Grok models free of reasoning controls", () => { + expect( + buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, + ).toEqual([]); + }); +}); + describe("buildInitialGrokProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5a..44f9369b82 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -29,7 +29,12 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", @@ -99,6 +104,104 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { + readonly options: ReadonlyArray<{ + value: string; + label: string; + description?: string; + isDefault?: boolean; + }>; + readonly currentValue: string | undefined; +} { + const meta = model._meta; + if (!meta || meta.supportsReasoningEffort === false) { + return { options: [], currentValue: undefined }; + } + + const currentEffort = nonEmptyString(meta.reasoningEffort); + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; + const seen = new Set(); + const options: Array<{ + value: string; + label: string; + description?: string; + advertisedDefault: boolean; + }> = []; + + for (const entry of advertisedOptions) { + if (!isRecord(entry)) { + continue; + } + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = + rawValue && isValidGrokReasoningEffortToken(rawValue) + ? rawValue + : rawId && isValidGrokReasoningEffortToken(rawId) + ? rawId + : undefined; + if (value === undefined || seen.has(value)) { + continue; + } + seen.add(value); + const description = nonEmptyString(entry.description); + options.push({ + value, + label: nonEmptyString(entry.label) ?? value, + ...(description ? { description } : {}), + advertisedDefault: entry.default === true || entry.isDefault === true, + }); + } + + const currentValue = + currentEffort && options.some((option) => option.value === currentEffort) + ? currentEffort + : undefined; + const advertisedDefaults = options.filter((option) => option.advertisedDefault); + const selectedDefault = + advertisedDefaults.find((option) => option.value === currentValue)?.value ?? + advertisedDefaults[0]?.value; + return { + options: options.map(({ value, label, description }) => ({ + value, + label, + ...(description ? { description } : {}), + ...(value === selectedDefault ? { isDefault: true } : {}), + })), + currentValue: currentValue ?? selectedDefault, + }; +} + +export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const reasoning = grokReasoningOptionsFromModel(model); + return reasoning.options.length > 0 + ? createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.isDefault ? { isDefault: true } : {}), + })), + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, + ], + }) + : EMPTY_CAPABILITIES; +} + function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -117,7 +220,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model), }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -161,6 +264,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -251,6 +355,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, @@ -264,6 +370,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -282,6 +389,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -302,6 +410,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d..9823a68708 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,6 +1,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -14,8 +15,10 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; +import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { + ApprovalRequestId, OpenCodeSettings, ProviderDriverKind, ProviderInstanceId, @@ -59,19 +62,48 @@ const runtimeMock = { startCalls: [] as string[], sessionCreateUrls: [] as string[], sessionCreateInputs: [] as Array>, + createdSessionIds: [] as string[], authHeaders: [] as Array, abortCalls: [] as string[], + abortSignals: [] as AbortSignal[], + abortImplementation: null as + | ((sessionID: string, signal?: AbortSignal) => Promise) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + messageCalls: [] as Array<{ sessionID: string; messageID: string }>, + messageFailures: 0, promptCalls: [] as Array, promptAsyncError: null as Error | null, + promptAsyncImplementation: null as (() => Promise) | null, + autoPromptEcho: true, + autoConnect: true, + promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], - subscribedEvents: [] as unknown[], + subscribedEvents: [] as Array>, + eventSubscribeObserved: null as (() => void) | null, + permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + questionReplyCalls: [] as Array<{ + requestID: string; + answers: ReadonlyArray>; + }>, + sessionStatus: "idle" as "idle" | "busy", + sessionStatusFailures: 0, + sessionStatusCalls: 0, + sessionStatusImplementation: null as (() => Promise) | null, sessionGetIds: [] as string[], + sessionGetObserved: null as ((sessionID: string) => void) | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), + sessionParentById: new Map(), + pendingPermissions: [] as Array, + pendingQuestions: [] as Array, + permissionListCalls: 0, + questionListCalls: 0, + permissionListImplementation: null as (() => Promise>) | null, + questionListImplementation: null as (() => Promise>) | null, sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, @@ -79,26 +111,50 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; this.state.sessionCreateInputs.length = 0; + this.state.createdSessionIds.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.abortSignals.length = 0; + this.state.abortImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; + this.state.messageCalls.length = 0; + this.state.messageFailures = 0; this.state.promptCalls.length = 0; this.state.promptAsyncError = null; + this.state.promptAsyncImplementation = null; + this.state.autoPromptEcho = true; + this.state.autoConnect = true; + this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.eventSubscribeObserved = null; + this.state.permissionReplyCalls.length = 0; + this.state.questionReplyCalls.length = 0; + this.state.sessionStatus = "idle"; + this.state.sessionStatusFailures = 0; + this.state.sessionStatusCalls = 0; + this.state.sessionStatusImplementation = null; this.state.sessionGetIds.length = 0; + this.state.sessionGetObserved = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); + this.state.sessionParentById.clear(); + this.state.pendingPermissions = []; + this.state.pendingQuestions = []; + this.state.permissionListCalls = 0; + this.state.questionListCalls = 0; + this.state.permissionListImplementation = null; + this.state.questionListImplementation = null; this.state.sessionUpdateCalls.length = 0; this.state.forkCalls.length = 0; }, }; const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => + startOpenCodeServerProcess: ({ binaryPath, serverPassword }) => Effect.gen(function* () { runtimeMock.state.startCalls.push(binaryPath); const url = "http://127.0.0.1:4301"; @@ -112,10 +168,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); return { url, + version: "1.15.13", + ...(serverPassword ? { serverPassword } : {}), exitCode: Effect.never, + isRunning: Effect.succeed(true), }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; // Always register a finalizer so the closeCalls/closeError probes fire; @@ -130,6 +189,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); return { url, + version: "1.15.13", + ...(serverPassword ? { serverPassword } : {}), exitCode: null, external: Boolean(serverUrl), }; @@ -144,10 +205,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); - return { data: { id: `${baseUrl}/session` } }; + return { + data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` }, + }; }, get: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.sessionGetIds.push(sessionID); + runtimeMock.state.sessionGetObserved?.(sessionID); // The real client is `throwOnError: true`: non-2xx rejects rather // than resolving, so missing → 404 throw, transient → 500 throw. if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { @@ -159,7 +223,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }); } const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); - return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + const parentID = runtimeMock.state.sessionParentById.get(sessionID); + return { + data: { + id: sessionID, + ...(directory ? { directory } : {}), + ...(parentID ? { parentID } : {}), + }, + }; }, update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); @@ -174,16 +245,73 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; }, - abort: async ({ sessionID }: { sessionID: string }) => { + abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { runtimeMock.state.abortCalls.push(sessionID); + if (options?.signal) { + runtimeMock.state.abortSignals.push(options.signal); + } + await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); + }, + status: async () => { + runtimeMock.state.sessionStatusCalls += 1; + if (runtimeMock.state.sessionStatusImplementation) { + return await runtimeMock.state.sessionStatusImplementation(); + } + if (runtimeMock.state.sessionStatusFailures > 0) { + runtimeMock.state.sessionStatusFailures -= 1; + throw new Error("status failed"); + } + return { + data: + runtimeMock.state.sessionStatus === "idle" + ? {} + : { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; }, promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); + await runtimeMock.state.promptAsyncImplementation?.(); if (runtimeMock.state.promptAsyncError) { throw runtimeMock.state.promptAsyncError; } + if ( + runtimeMock.state.autoPromptEcho && + typeof input === "object" && + input !== null && + "sessionID" in input && + "messageID" in input && + typeof input.sessionID === "string" && + typeof input.messageID === "string" + ) { + runtimeMock.state.messages.push({ + info: { id: input.messageID, role: "user" }, + parts: [], + }); + runtimeMock.state.promptEchoEvents.push({ + id: `evt-auto-user-${input.messageID}`, + type: "message.updated", + properties: { + sessionID: input.sessionID, + info: { id: input.messageID, role: "user" }, + }, + }); + } }, messages: async () => ({ data: runtimeMock.state.messages }), + message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { + runtimeMock.state.messageCalls.push({ sessionID, messageID }); + if (runtimeMock.state.messageFailures > 0) { + runtimeMock.state.messageFailures -= 1; + throw new Error("message lookup failed", { cause: { status: 500 } }); + } + const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID); + if (!message) { + throw new Error(`Message not found: ${messageID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + } + return { data: message }; + }, revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { runtimeMock.state.revertCalls.push({ sessionID, @@ -204,13 +332,55 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, }, event: { - subscribe: async () => ({ - stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; - } - })(), - }), + subscribe: async () => { + runtimeMock.state.eventSubscribeObserved?.(); + return { + stream: (async function* () { + if (runtimeMock.state.autoConnect) { + yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; + } + for (const event of runtimeMock.state.subscribedEvents) { + const resolved = await event; + while (runtimeMock.state.promptEchoEvents.length > 0) { + yield runtimeMock.state.promptEchoEvents.shift(); + } + yield resolved; + } + })(), + }; + }, + }, + permission: { + list: async () => { + runtimeMock.state.permissionListCalls += 1; + return { + data: runtimeMock.state.permissionListImplementation + ? await runtimeMock.state.permissionListImplementation() + : runtimeMock.state.pendingPermissions, + }; + }, + reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + }, + }, + question: { + list: async () => { + runtimeMock.state.questionListCalls += 1; + return { + data: runtimeMock.state.questionListImplementation + ? await runtimeMock.state.questionListImplementation() + : runtimeMock.state.pendingQuestions, + }; + }, + reply: async ({ + requestID, + answers, + }: { + requestID: string; + answers: ReadonlyArray>; + }) => { + runtimeMock.state.questionReplyCalls.push({ requestID, answers }); + }, }, }) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -280,6 +450,37 @@ beforeEach(() => { const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); +function promiseWithResolvers() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ + id, + sessionID, + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], +}); + +const questionRequest = (id: string, sessionID: string): QuestionRequest => ({ + id, + sessionID, + questions: [ + { + header: "Scope", + question: "Which scope should OpenCode use?", + options: [{ label: "Workspace", description: "Use this workspace." }], + }, + ], +}); + it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { @@ -301,6 +502,348 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("fails startup when the OpenCode event stream does not connect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-connect-timeout"); + runtimeMock.state.autoConnect = false; + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + + const result = yield* Fiber.join(startFiber); + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.method, "event.subscribe"); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("closes a connecting session when startup is interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-connect-interrupted"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + yield* Effect.yieldNow; + yield* Fiber.interrupt(startFiber); + + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stops a connecting session and rejects its waiting send", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-connecting"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const connecting = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.equal(connecting?.status, "connecting"); + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Must not be sent", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + + yield* adapter.stopSession(threadId); + const startResult = yield* Fiber.join(startFiber); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(startResult._tag, "Failure"); + NodeAssert.equal(sendResult._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("aborts a held teardown request before closing the session scope", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-teardown-timeout"); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + yield* advanceTestClock(999); + NodeAssert.equal(stopFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortSignals.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, false); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, []); + + yield* advanceTestClock(1); + yield* Fiber.join(stopFiber); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stopAll closes a connecting session and releases startup", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-all-connecting"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const sessionCount = (yield* adapter.listSessions()).length; + + yield* adapter.stopAll(); + const startResult = yield* Fiber.join(startFiber); + NodeAssert.equal(startResult._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.closeCalls.length, sessionCount); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("keeps one session when concurrent starts cross the connection barrier", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-concurrent-start"); + const connectionEvent = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.createdSessionIds.push("ses_race_a", "ses_race_b"); + runtimeMock.state.subscribedEvents = [connectionEvent.promise]; + + const firstStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const secondStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + connectionEvent.resolve({ + id: "evt-concurrent-start-connected", + type: "server.connected", + properties: {}, + }); + + const [firstSession, secondSession] = yield* Effect.all([ + Fiber.join(firstStart), + Fiber.join(secondStart), + ]); + const sessions = yield* adapter.listSessions(); + const threadSessions = sessions.filter((session) => session.threadId === threadId); + NodeAssert.equal(threadSessions.length, 1); + NodeAssert.deepEqual(firstSession.resumeCursor, secondSession.resumeCursor); + NodeAssert.equal(firstSession.status, "ready"); + NodeAssert.equal(secondSession.status, "ready"); + const winnerId = (threadSessions[0]?.resumeCursor as { sessionId?: string } | undefined) + ?.sessionId; + NodeAssert.ok(winnerId === "ses_race_a" || winnerId === "ses_race_b"); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + winnerId === "ses_race_a" ? "ses_race_b" : "ses_race_a", + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reuses a published connecting session after it becomes ready", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reuse-connecting"); + const connectionEvent = promiseWithResolvers(); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + runtimeMock.state.subscribedEvents = [connectionEvent.promise]; + + const owningStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const reusedStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.sessionCreateUrls.length, 1); + + connectionEvent.resolve({ + id: "evt-reused-start-connected", + type: "server.connected", + properties: {}, + }); + const [ownedSession, reusedSession] = yield* Effect.all([ + Fiber.join(owningStart), + Fiber.join(reusedStart), + ]); + NodeAssert.equal(ownedSession.status, "ready"); + NodeAssert.equal(reusedSession.status, "ready"); + NodeAssert.deepEqual(ownedSession.resumeCursor, reusedSession.resumeCursor); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not let an old held stop delete its replacement", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-old-stop-replacement"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.createdSessionIds.push("ses_old", "ses_replacement"); + + const oldSession = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + const replacement = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + NodeAssert.deepEqual(oldSession.resumeCursor, { schemaVersion: 1, sessionId: "ses_old" }); + NodeAssert.deepEqual(replacement.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_replacement", + }); + + abortRelease.resolve(undefined); + yield* Fiber.join(oldStop); + const current = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("replaces a stopped connecting session while its teardown is held", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stopped-connecting-retry"); + const eventSubscribeObserved = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + runtimeMock.state.createdSessionIds.push("ses_connecting_old", "ses_connecting_replacement"); + + const oldStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + runtimeMock.state.autoConnect = true; + runtimeMock.state.abortImplementation = null; + const replacement = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + NodeAssert.equal(replacement.status, "ready"); + NodeAssert.deepEqual(replacement.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_connecting_replacement", + }); + + abortRelease.resolve(undefined); + const oldStartResult = yield* Fiber.join(oldStart); + yield* Fiber.join(oldStop); + NodeAssert.equal(oldStartResult._tag, "Failure"); + const current = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("returns a durable resume cursor for a freshly created session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -809,95 +1352,3099 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { + it.effect("does not let an old idle status complete a successful steer", () => + Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-idle-admission"); + const busyBeforeSteer = promiseWithResolvers(); + const idleBeforeSteer = promiseWithResolvers(); + const idleAfterSteer = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + busyBeforeSteer.promise, + idleBeforeSteer.promise, + idleAfterSteer.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 3) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-custom-instance"), + threadId, runtimeMode: "full-access", }); - - yield* adapter.sendTurn({ - threadId: asThreadId("thread-custom-instance"), - input: "Fix it", + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - [ - { id: "agent", value: "github-copilot" }, - { id: "variant", value: "high" }, - ], + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", ), }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - agent: "github-copilot", - variant: "high", - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-fallback-model"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ threadId, - runtimeMode: "full-access", + input: "Start the next turn", modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", ), }); - - yield* adapter.sendTurn({ - threadId, - input: "Fix it", + busyBeforeSteer.resolve({ + id: "evt-busy-before-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", + idleBeforeSteer.resolve({ + id: "evt-idle-before-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, }, - parts: [{ type: "text", text: "Fix it" }], }); - }).pipe(Effect.provide(adapterLayer)); - }); - + yield* Effect.promise(() => statusStarted.promise); + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add one more task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + statusRelease.resolve(undefined); + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, activeTurn.turnId); + + idleAfterSteer.resolve({ + id: "evt-idle-after-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("waits for steer admission before accepting the only idle event", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-admission-only-idle"); + runtimeMock.state.autoPromptEcho = false; + const firstUserMessageEvent = promiseWithResolvers(); + const staleIdleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + staleIdleEvent.promise, + userMessageEvent.promise, + idleEvent.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + NodeAssert.match(firstMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.match(steerMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + firstUserMessageEvent.resolve({ + id: "evt-delayed-first-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + staleIdleEvent.resolve({ + id: "evt-stale-idle-during-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + userMessageEvent.resolve({ + id: "evt-steer-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: steerMessageId, role: "user" }, + }, + }); + idleEvent.resolve({ + id: "evt-only-idle-during-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 0); + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + }), + ); + + it.effect("keeps steer admission until its user message arrives after prompt acceptance", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-message-after-acceptance"); + runtimeMock.state.autoPromptEcho = false; + const firstUserMessageEvent = promiseWithResolvers(); + const staleIdleEvent = promiseWithResolvers(); + const steerUserMessageEvent = promiseWithResolvers(); + const validIdleEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + staleIdleEvent.promise, + steerUserMessageEvent.promise, + validIdleEvent.promise, + ]; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-message-before-steer", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + + yield* adapter.sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + + staleIdleEvent.resolve({ + id: "evt-stale-idle-after-steer-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + const sessionsAfterStaleIdle = yield* adapter.listSessions(); + const sessionAfterStaleIdle = sessionsAfterStaleIdle.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterStaleIdle?.status, "running"); + NodeAssert.equal(sessionAfterStaleIdle?.activeTurnId, activeTurn.turnId); + + steerUserMessageEvent.resolve({ + id: "evt-steer-user-message-after-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: steerMessageId, role: "user" }, + }, + }); + validIdleEvent.resolve({ + id: "evt-valid-idle-after-steer-message", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("recovers steer admission when reconnect happens before prompt acceptance", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-reconnect-before-acceptance"); + const firstUserMessageEvent = promiseWithResolvers(); + const reconnectEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [firstUserMessageEvent.promise, reconnectEvent.promise]; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-before-reconnect-steer", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + NodeAssert.ok(steerMessageId); + runtimeMock.state.messages.push({ + info: { id: steerMessageId, role: "user" }, + parts: [], + }); + runtimeMock.state.messageFailures = 1; + reconnectEvent.resolve({ + id: "evt-reconnected-during-steer", + type: "server.connected", + properties: {}, + }); + yield* Effect.yieldNow; + + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal( + runtimeMock.state.messageCalls.filter((call) => call.messageID === steerMessageId).length, + 2, + ); + const abortCallsAfterCompletion = runtimeMock.state.abortCalls.length; + yield* adapter.interruptTurn(threadId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.abortCalls.length, abortCallsAfterCompletion); + }), + ); + + it.effect("resolves admission without a prompt echo when busy and idle still arrive", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-admission-without-echo"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined; + if (prompt?.messageID) { + runtimeMock.state.messages.push({ + info: { id: prompt.messageID, role: "user" }, + parts: [], + }); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run without an echo event", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-busy-without-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-idle-without-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* advanceTestClock(1_000); + + NodeAssert.equal( + runtimeMock.state.messageCalls.some( + (call) => call.messageID === runtimeMock.state.messages[0]?.info.id, + ), + true, + ); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal(turn.turnId !== undefined, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("uses polled busy status to admit output after a stopped turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-polled-busy-after-stop"); + const firstUserMessageEvent = promiseWithResolvers(); + const assistantMessageEvent = promiseWithResolvers(); + const assistantPartEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const busyStatusPolled = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + assistantMessageEvent.promise, + assistantPartEvent.promise, + idleEvent.promise, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "content.delta" || event.type === "turn.completed"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const stoppedMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-before-polled-busy-turn", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: stoppedMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + runtimeMock.state.sessionStatusCalls = 0; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + busyStatusPolled.resolve(undefined); + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run without echo or busy events", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.promise(() => busyStatusPolled.promise); + yield* Effect.yieldNow; + + assistantMessageEvent.resolve({ + id: "evt-assistant-after-polled-busy", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-assistant-after-polled-busy", role: "assistant" }, + }, + }); + assistantPartEvent.resolve({ + id: "evt-part-after-polled-busy", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-after-polled-busy", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-assistant-after-polled-busy", + type: "text", + text: "Visible output", + time: { start: 1 }, + }, + time: 1, + }, + }); + idleEvent.resolve({ + id: "evt-idle-after-polled-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["content.delta", "turn.completed"], + ); + const delta = events[0]; + if (delta?.type === "content.delta") { + NodeAssert.equal(delta.payload.delta, "Visible output"); + } + NodeAssert.equal(events[1]?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a stale admission status response after the next turn starts", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-admission-status-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const staleStatusStarted = promiseWithResolvers(); + const staleStatusRelease = promiseWithResolvers(); + const staleStatusReturned = promiseWithResolvers(); + const activePromptStarted = promiseWithResolvers(); + const activePromptRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + staleStatusStarted.resolve(undefined); + await staleStatusRelease.promise; + staleStatusReturned.resolve(undefined); + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + activePromptStarted.resolve(undefined); + await activePromptRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop while status is pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.promise(() => staleStatusStarted.promise); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + const activeTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start while the old status is pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => activePromptStarted.promise); + const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + + staleStatusRelease.resolve(undefined); + yield* Effect.promise(() => staleStatusReturned.promise); + for (let index = 0; index < 2; index += 1) { + yield* Effect.yieldNow; + } + idleEvent.resolve({ + id: "evt-idle-after-stale-admission-status", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 4; index += 1) { + yield* Effect.yieldNow; + } + NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(completedFiber.pollUnsafe(), undefined); + const sessionsBeforeAcceptance = yield* adapter.listSessions(); + const sessionBeforeAcceptance = sessionsBeforeAcceptance.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeAcceptance?.status, "running"); + NodeAssert.notEqual(sessionBeforeAcceptance?.activeTurnId, stoppedTurn.turnId); + + userMessageEvent.resolve({ + id: "evt-user-after-stale-admission-status", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + activePromptRelease.resolve(undefined); + const activeTurn = yield* Fiber.join(activeTurnFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles a sole idle when the matching prompt echo arrives later", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-before-delayed-echo"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Finish before the echo arrives", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const messageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + idleEvent.resolve({ + id: "evt-idle-before-delayed-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + userMessageEvent.resolve({ + id: "evt-delayed-matching-echo", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: messageId, role: "user" }, + }, + }); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, turn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles the only idle after a stopped turn when the prompt echo is missing", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-only-without-echo-after-stop"); + const idleEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined; + if (prompt?.messageID) { + runtimeMock.state.messages.push({ + info: { id: prompt.messageID, role: "user" }, + parts: [], + }); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const activeMessageId = ( + runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined + )?.messageID; + idleEvent.resolve({ + id: "evt-only-idle-without-echo-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* advanceTestClock(1_000); + + NodeAssert.equal( + runtimeMock.state.messageCalls.some((call) => call.messageID === activeMessageId), + true, + ); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.notEqual(activeTurn.turnId, stoppedTurn.turnId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles a sole idle after a stop when the exact prompt echo arrives", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-before-exact-echo-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const activeMessageId = ( + runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined + )?.messageID; + idleEvent.resolve({ + id: "evt-only-idle-before-exact-echo-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + const sessionsBeforeEcho = yield* adapter.listSessions(); + const sessionBeforeEcho = sessionsBeforeEcho.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeEcho?.status, "running"); + NodeAssert.equal(sessionBeforeEcho?.activeTurnId, activeTurn.turnId); + + userMessageEvent.resolve({ + id: "evt-exact-prompt-echo-after-stop", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("recovers an idle before the exact prompt echo while acceptance is held", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-and-echo-before-acceptance-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const activePromptStarted = promiseWithResolvers(); + const activePromptRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + activePromptStarted.resolve(undefined); + await activePromptRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + const activeTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => activePromptStarted.promise); + const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + idleEvent.resolve({ + id: "evt-idle-before-held-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + userMessageEvent.resolve({ + id: "evt-exact-echo-before-held-prompt-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined); + + activePromptRelease.resolve(undefined); + const activeTurn = yield* Fiber.join(activeTurnFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("restores idle reconciliation after a steer prompt fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-failed-steer-idle"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const firstStatusStarted = promiseWithResolvers(); + const firstStatusRelease = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + firstStatusStarted.resolve(undefined); + await firstStatusRelease.promise; + } + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 3) { + steerStarted.resolve(undefined); + await steerRelease.promise; + throw new Error("steer failed"); + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start the next turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-failed-steer-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-failed-steer-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => firstStatusStarted.promise); + const steerFiber = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "This steer fails", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }), + ).pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + firstStatusRelease.resolve(undefined); + steerRelease.resolve(undefined); + const steerExit = yield* Fiber.join(steerFiber); + NodeAssert.equal(Exit.isFailure(steerExit), true); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2); + }), + ); + + it.effect("accepts the only idle event after a steer fails before creating its message", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-failed-steer-admission-idle"); + const idleEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + throw new Error("steer failed before message creation"); + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerFiber = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "This steer fails", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }), + ).pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + idleEvent.resolve({ + id: "evt-idle-during-failed-admission", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + steerRelease.resolve(undefined); + NodeAssert.equal(Exit.isFailure(yield* Fiber.join(steerFiber)), true); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("routes child-session approval requests and replies through the parent thread", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-approval"); + const permissionReply = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child", + info: { + id: "ses_child", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-child-permission", + type: "permission.asked", + properties: { + id: "per_child", + sessionID: "ses_child", + permission: "external_directory", + patterns: ["/tmp/external/*"], + metadata: { source: "child" }, + always: ["/tmp/external/*"], + }, + }, + permissionReply.promise, + ]; + + const openedEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const openedEvents = Array.from( + yield* Fiber.join(openedEventsFiber).pipe(Effect.timeout("1 second")), + ); + const opened = openedEvents.find((event) => event.type === "request.opened"); + NodeAssert.ok(opened); + NodeAssert.equal(opened.requestId, "per_child"); + NodeAssert.equal( + opened.raw?.source === "opencode.sdk.event" && + typeof opened.raw.payload === "object" && + opened.raw.payload !== null && + "properties" in opened.raw.payload + ? (opened.raw.payload.properties as { sessionID?: string }).sessionID + : undefined, + "ses_child", + ); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("per_child"), + "acceptForSession", + ); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_child", reply: "always" }, + ]); + + const resolvedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + permissionReply.resolve({ + id: "evt-child-permission-replied", + type: "permission.replied", + properties: { + sessionID: "ses_child", + requestID: "per_child", + reply: "always", + }, + }); + const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "request.resolved"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("routes child-session questions and replies through the parent thread", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-question"); + const questionReply = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_question", + info: { + id: "ses_child_question", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-child-question", + type: "question.asked", + properties: { + id: "que_child", + sessionID: "ses_child_question", + questions: [ + { + header: "Scope", + question: "Which scope should OpenCode use?", + options: [{ label: "Workspace", description: "Use this workspace." }], + }, + ], + }, + }, + questionReply.promise, + ]; + + const requestedEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const requestedEvents = Array.from( + yield* Fiber.join(requestedEventsFiber).pipe(Effect.timeout("1 second")), + ); + const requested = requestedEvents.find((event) => event.type === "user-input.requested"); + NodeAssert.ok(requested); + NodeAssert.equal(requested.requestId, "que_child"); + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_child"), { + Scope: "Workspace", + }); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [ + { requestID: "que_child", answers: [["Workspace"]] }, + ]); + + const resolvedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + questionReply.resolve({ + id: "evt-child-question-replied", + type: "question.replied", + properties: { + sessionID: "ses_child_question", + requestID: "que_child", + answers: [["Workspace"]], + }, + }); + const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "user-input.resolved"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("recovers pending requests from existing nested child sessions on resume", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-resume-child-requests"); + runtimeMock.state.sessionParentById.set("ses_child", "ses_parent"); + runtimeMock.state.sessionParentById.set("ses_nested", "ses_child"); + runtimeMock.state.pendingPermissions = [permissionRequest("per_existing", "ses_nested")]; + runtimeMock.state.pendingQuestions = [questionRequest("que_existing", "ses_child")]; + + const requestsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + + const requests = Array.from( + yield* Fiber.join(requestsFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.deepEqual(requests.map((event) => [event.type, event.requestId]).sort(), [ + ["request.opened", "per_existing"], + ["user-input.requested", "que_existing"], + ]); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_existing"), "accept"); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_existing"), { + Scope: "Workspace", + }); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_existing", reply: "once" }, + ]); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [ + { requestID: "que_existing", answers: [["Workspace"]] }, + ]); + }), + ); + + it.effect("retries ancestry for one live child request after a transient failure", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-request-ancestry-retry"); + const parentId = "http://127.0.0.1:9999/session"; + const ancestryAttempted = promiseWithResolvers(); + runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); + runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === "ses_existing_child") { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-existing-child-permission", + type: "permission.asked", + properties: permissionRequest("per_retry", "ses_existing_child"), + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "runtime.warning" || event.type === "request.opened"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); + yield* advanceTestClock(250); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["runtime.warning", "request.opened"], + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); + }), + ); + + it.effect("does not resurrect a recovered child request after its live reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-child-request-recovery"); + const listStarted = promiseWithResolvers(); + const listRelease = promiseWithResolvers(); + const stale = permissionRequest("per_stale", "ses_existing_child"); + runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent"); + runtimeMock.state.permissionListImplementation = async () => { + listStarted.resolve(undefined); + await listRelease.promise; + return [stale]; + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-stale-child-replied", + type: "permission.replied", + properties: { + sessionID: "ses_existing_child", + requestID: stale.id, + reply: "once", + }, + }, + ]; + + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + yield* Effect.promise(() => listStarted.promise); + const resolved = Option.getOrUndefined( + yield* Fiber.join(resolvedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(resolved?.type, "request.resolved"); + listRelease.resolve(undefined); + yield* Effect.yieldNow; + + const response = yield* Effect.exit( + adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"), + ); + NodeAssert.equal(Exit.isFailure(response), true); + }), + ); + + it.effect("lets a child reply supersede an ask while ancestry lookup is retrying", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-terminal-during-ancestry"); + const ancestryAttempted = promiseWithResolvers(); + const childId = "ses_terminal_child"; + const request = permissionRequest("per_terminal", childId); + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-terminal-ask", type: "permission.asked", properties: request }, + { + id: "evt-terminal-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }, + ]; + + const terminalFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + + const terminal = Option.getOrUndefined( + yield* Fiber.join(terminalFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(terminal?.type, "request.resolved"); + const response = yield* Effect.exit( + adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"), + ); + NodeAssert.equal(Exit.isFailure(response), true); + }), + ); + + it.effect("caps terminal ancestry retries after a request finishes", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-terminal-ancestry-retry-cap"); + const childId = "ses_terminal_retry_cap_child"; + const request = permissionRequest("per_terminal_retry_cap", childId); + const terminalEvent = promiseWithResolvers(); + const askedAttempted = promiseWithResolvers(); + const terminalAttempted = promiseWithResolvers(); + let terminalReleased = false; + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID !== childId) { + return; + } + if (terminalReleased) { + terminalAttempted.resolve(undefined); + } else { + askedAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-terminal-cap-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const unexpectedRequestFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => askedAttempted.promise); + const askedAttempts = runtimeMock.state.sessionGetIds.filter( + (sessionID) => sessionID === childId, + ).length; + + terminalReleased = true; + terminalEvent.resolve({ + id: "evt-terminal-cap-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => terminalAttempted.promise); + yield* advanceTestClock(10_000); + const callsAfterCap = runtimeMock.state.sessionGetIds.filter( + (sessionID) => sessionID === childId, + ).length; + NodeAssert.equal(callsAfterCap - askedAttempts, 5); + + yield* advanceTestClock(30_000); + NodeAssert.equal( + runtimeMock.state.sessionGetIds.filter((sessionID) => sessionID === childId).length, + callsAfterCap, + ); + NodeAssert.equal(unexpectedRequestFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(unexpectedRequestFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reruns recovery when the event stream connects during the startup snapshot", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-connected-recovery-rerun"); + const firstListStarted = promiseWithResolvers(); + const firstListRelease = promiseWithResolvers(); + const pending = permissionRequest("per_connected", "ses_existing_child"); + runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent"); + runtimeMock.state.permissionListImplementation = async () => { + if (runtimeMock.state.permissionListCalls === 1) { + firstListStarted.resolve(undefined); + await firstListRelease.promise; + return []; + } + return [pending]; + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-connected", type: "server.connected", properties: {} }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + yield* Effect.promise(() => firstListStarted.promise); + firstListRelease.resolve(undefined); + + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, pending.id); + NodeAssert.equal(runtimeMock.state.permissionListCalls, 2); + }), + ); + + it.effect("keeps an idle event from completing a turn while its abort request is pending", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-idle-race"); + const idleEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + idleEvent.resolve({ + id: "evt-idle-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores late busy and idle status after an interrupted turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-late-status-after-interrupt"); + const lateBusy = promiseWithResolvers(); + const lateIdle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [lateBusy.promise, lateIdle.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, turn.turnId); + + lateBusy.resolve({ + id: "evt-late-busy-after-interrupt", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + lateIdle.resolve({ + id: "evt-late-idle-after-interrupt", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + }), + ); + + it.effect("rejects a prompt accepted after its turn was interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-during-prompt-admission"); + const promptStarted = promiseWithResolvers(); + const promptRelease = promiseWithResolvers(); + const lateBusy = promiseWithResolvers(); + const lateMessage = promiseWithResolvers(); + const latePart = promiseWithResolvers(); + const lateIdle = promiseWithResolvers(); + const marker = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + lateBusy.promise, + lateMessage.promise, + latePart.promise, + lateIdle.promise, + marker.promise, + ]; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 1) { + promptStarted.resolve(undefined); + await promptRelease.promise; + } + }; + + const firstLateOutput = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "content.delta" || event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This request is still pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => promptStarted.promise); + + yield* adapter.interruptTurn(threadId); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + const sessionsAfterStop = yield* adapter.listSessions(); + const sessionAfterStop = sessionsAfterStop.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterStop?.status, "ready"); + NodeAssert.equal(sessionAfterStop?.activeTurnId, undefined); + + promptRelease.resolve(undefined); + const sendResult = yield* Fiber.join(sendFiber); + lateBusy.resolve({ + id: "evt-busy-after-late-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + lateMessage.resolve({ + id: "evt-assistant-after-late-prompt-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-late-assistant", role: "assistant" }, + }, + }); + latePart.resolve({ + id: "evt-part-after-late-prompt-acceptance", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-late-assistant", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-late-assistant", + type: "text", + text: "Late output", + time: { start: 1 }, + }, + time: 1, + }, + }); + lateIdle.resolve({ + id: "evt-idle-after-late-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + marker.resolve({ + id: "evt-marker-after-late-prompt-acceptance", + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Late prompt cleaned up", + }, + }, + }); + + const firstOutput = Option.getOrUndefined( + yield* Fiber.join(firstLateOutput).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(firstOutput?.type, "thread.metadata.updated"); + NodeAssert.equal(Exit.isFailure(sendResult), true); + if (Exit.isFailure(sendResult)) { + NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true); + } + + yield* adapter.sendTurn({ + threadId, + input: "Start after late cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + const sessionsAfterNextTurn = yield* adapter.listSessions(); + const sessionAfterNextTurn = sessionsAfterNextTurn.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterNextTurn?.status, "running"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("treats MessageAbortedError as the acknowledgment for a pending user stop", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-error-race"); + const abortedEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortedEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + abortedEvent.resolve({ + id: "evt-aborted-after-stop", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + yield* Effect.yieldNow; + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => event.type), + ["turn.aborted"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not claim a turn stopped when the abort request fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-request-failure"); + runtimeMock.state.abortImplementation = async () => { + throw new Error("abort failed"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const exit = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId)); + NodeAssert.equal(Exit.isFailure(exit), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + }), + ); + + it.effect("releases stop and send waiters when a native abort times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-timeout"); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + runtimeMock.state.sessionStatus = "busy"; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const unexpectedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || event.type === "turn.aborted"), + ), + Stream.runHead, + Effect.forkChild, + ); + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals.length, 1); + const abortSignal = runtimeMock.state.abortSignals[0]; + const secondInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Wait for the stop request", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + + yield* advanceTestClock(9_999); + NodeAssert.equal(firstInterrupt.pollUnsafe(), undefined); + NodeAssert.equal(secondInterrupt.pollUnsafe(), undefined); + NodeAssert.equal(sendFiber.pollUnsafe(), undefined); + yield* advanceTestClock(1); + + const firstResult = yield* Fiber.join(firstInterrupt); + const secondResult = yield* Fiber.join(secondInterrupt); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(firstResult._tag, "Failure"); + NodeAssert.equal(secondResult._tag, "Failure"); + NodeAssert.equal(sendResult._tag, "Failure"); + if (firstResult._tag === "Failure") { + NodeAssert.equal(firstResult.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal( + firstResult.failure.detail, + "OpenCode session abort did not complete within 10 seconds.", + ); + } + NodeAssert.equal(abortSignal?.aborted, true); + NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined); + + runtimeMock.state.abortImplementation = null; + yield* adapter.sendTurn({ + threadId, + input: "Continue after the failed stop request", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + yield* Fiber.interrupt(unexpectedEventFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("shares one abort request across concurrent stops", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-concurrent-interrupt"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + const secondInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + + abortRelease.resolve(undefined); + yield* Fiber.join(firstInterrupt); + yield* Fiber.join(secondInterrupt); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("accepts a native turnless abort before its request times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-turnless-interrupt"); + const abortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const acknowledgmentFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error" || + event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + const firstInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + const secondInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + runtimeMock.state.sessionStatusImplementation = async () => ({ + data: { ses_existing: { type: "busy" as const } }, + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after the session abort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + + abortEvent.resolve({ + id: "evt-turnless-abort", + type: "session.error", + properties: { + sessionID: "ses_existing", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-turnless-abort", + type: "session.updated", + properties: { + info: { id: "ses_existing", title: "Turnless abort acknowledged" }, + }, + }); + const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber)); + NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated"); + const unexpectedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* advanceTestClock(10_000); + yield* Fiber.join(firstInterrupt); + yield* Fiber.join(secondInterrupt); + yield* Fiber.join(sendFiber); + + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true); + NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(unexpectedEventFiber); + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a native turnless abort after its request succeeds", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-late-turnless-abort"); + const abortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise]; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const acknowledgmentFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error" || + event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.interruptTurn(threadId); + abortEvent.resolve({ + id: "evt-late-turnless-abort", + type: "session.error", + properties: { + sessionID: "ses_existing", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-late-turnless-abort", + type: "session.updated", + properties: { + info: { id: "ses_existing", title: "Late turnless abort ignored" }, + }, + }); + const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber)); + + NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated"); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("clears a failed turnless interrupt before the next turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-turnless-interrupt-failure"); + runtimeMock.state.abortImplementation = async () => { + throw new Error("abort failed"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const interruptExit = yield* Effect.exit(adapter.interruptTurn(threadId)); + NodeAssert.equal(Exit.isFailure(interruptExit), true); + + runtimeMock.state.abortImplementation = null; + yield* adapter.sendTurn({ + threadId, + input: "Start after the failed session abort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("waits for a pending stop before starting the next turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-send-during-stop"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const stopFiber = yield* adapter + .interruptTurn(threadId, stoppedTurn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + abortRelease.resolve(undefined); + yield* Fiber.join(stopFiber); + const nextTurn = yield* Fiber.join(sendFiber); + + NodeAssert.notEqual(nextTurn.turnId, stoppedTurn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + }), + ); + + it.effect("interrupts a turn waiting on cancellation when the session stops", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stop-during-cancellation"); + const firstAbortStarted = promiseWithResolvers(); + const teardownAbortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + if (runtimeMock.state.abortCalls.length === 1) { + firstAbortStarted.resolve(undefined); + } else { + teardownAbortStarted.resolve(undefined); + } + await abortRelease.promise; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const interruptFiber = yield* adapter + .interruptTurn(threadId, activeTurn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => firstAbortStarted.promise); + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Must not be sent", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(Exit.isFailure(sendResult), true); + if (Exit.isFailure(sendResult)) { + NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true); + } + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + yield* Effect.promise(() => teardownAbortStarted.promise); + yield* advanceTestClock(1_000); + yield* Fiber.join(stopFiber); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("rechecks a newer idle after an older status call returns busy", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-newer-idle-during-status"); + const busyEvent = promiseWithResolvers(); + const staleIdle = promiseWithResolvers(); + const realIdle = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, staleIdle.promise, realIdle.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-new-turn-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + staleIdle.resolve({ + id: "evt-old-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => statusStarted.promise); + realIdle.resolve({ + id: "evt-new-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + statusRelease.resolve(undefined); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, secondTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2); + }), + ); + + it.effect("completes after transient status failures without another idle event", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-status-retry"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const failuresObserved = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls <= 2) { + if (runtimeMock.state.sessionStatusCalls === 2) { + failuresObserved.resolve(undefined); + } + throw new Error("status failed"); + } + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-retry-turn-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-retry-turn-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => failuresObserved.promise); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, secondTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 3); + }), + ); + + it.effect("keeps idle reconciliation after a delayed abort from the stopped turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-abort-during-idle-check"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const staleAbortEvent = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + busyEvent.promise, + idleEvent.promise, + staleAbortEvent.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-stale-abort-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-stale-abort-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => statusStarted.promise); + staleAbortEvent.resolve({ + id: "evt-delayed-old-abort", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + statusRelease.resolve(undefined); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("keeps the newer turn running while status lookup keeps failing", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-status-permanent-failure"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const firstAttemptFailed = promiseWithResolvers(); + const retryAttemptFailed = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 2) { + firstAttemptFailed.resolve(undefined); + } + if (runtimeMock.state.sessionStatusCalls === 4) { + retryAttemptFailed.resolve(undefined); + } + throw new Error("status remains unavailable"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-permanent-failure-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-permanent-failure-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => firstAttemptFailed.promise); + yield* advanceTestClock(250); + yield* Effect.promise(() => retryAttemptFailed.promise); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, activeTurn.turnId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores delayed stop events around the next turn startup", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-delayed-interrupt-events"); + const staleIdleBeforeBusy = promiseWithResolvers(); + const nextBusy = promiseWithResolvers(); + const nextUserMessage = promiseWithResolvers(); + const staleAbort = promiseWithResolvers(); + const staleIdle = promiseWithResolvers(); + const secondStaleIdle = promiseWithResolvers(); + const nextIdle = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + staleIdleBeforeBusy.promise, + nextBusy.promise, + nextUserMessage.promise, + staleAbort.promise, + staleIdle.promise, + secondStaleIdle.promise, + nextIdle.promise, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(6), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const secondMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + + staleIdleBeforeBusy.resolve({ + id: "evt-delayed-idle-before-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 2; index += 1) { + yield* Effect.yieldNow; + } + const sessionsBeforeBusy = yield* adapter.listSessions(); + const sessionBeforeBusy = sessionsBeforeBusy.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeBusy?.status, "running"); + NodeAssert.equal(sessionBeforeBusy?.activeTurnId, secondTurn.turnId); + + runtimeMock.state.sessionStatus = "busy"; + nextBusy.resolve({ + id: "evt-next-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + nextUserMessage.resolve({ + id: "evt-next-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: secondMessageId, role: "user" }, + }, + }); + staleAbort.resolve({ + id: "evt-delayed-abort", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + staleIdle.resolve({ + id: "evt-delayed-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + secondStaleIdle.resolve({ + id: "evt-second-delayed-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 4; index += 1) { + yield* Effect.yieldNow; + } + + const sessionsBeforeRealIdle = yield* adapter.listSessions(); + const sessionBeforeRealIdle = sessionsBeforeRealIdle.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeRealIdle?.status, "running"); + NodeAssert.equal(sessionBeforeRealIdle?.activeTurnId, secondTurn.turnId); + + runtimeMock.state.sessionStatus = "idle"; + nextIdle.resolve({ + id: "evt-next-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => ({ type: event.type, turnId: event.turnId })), + [ + { type: "turn.aborted", turnId: firstTurn.turnId }, + { type: "turn.completed", turnId: secondTurn.turnId }, + ], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a genuine provider error visible during a pending user stop", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-provider-error"); + const errorEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [errorEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + errorEvent.resolve({ + id: "evt-provider-error-after-stop", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { + name: "APIError", + data: { message: "Upstream failed", isRetryable: false }, + }, + }, + }); + yield* Effect.yieldNow; + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => event.type), + ["turn.completed", "runtime.error"], + ); + const failed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal( + failed?.type === "turn.completed" ? failed.payload.state : undefined, + "failed", + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-custom-instance"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-custom-instance"), + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + [ + { id: "agent", value: "github-copilot" }, + { id: "variant", value: "high" }, + ], + ), + }); + + const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as { + messageID: string; + [key: string]: unknown; + }; + NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.deepEqual(prompt, { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + agent: "github-copilot", + variant: "high", + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-fallback-model"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + ), + }); + + yield* adapter.sendTurn({ + threadId, + input: "Fix it", + }); + + const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as { + messageID: string; + [key: string]: unknown; + }; + NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.deepEqual(prompt, { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + it.effect("rejects sendTurn model selections for another instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( @@ -1289,6 +4836,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, }, }, + { + id: "evt-unrelated-child", + type: "session.created", + properties: { + sessionID: "ses_unrelated_child", + info: { + id: "ses_unrelated_child", + parentID: "ses_unrelated_parent", + title: "Unrelated child", + }, + }, + }, + { + id: "evt-unrelated-permission", + type: "permission.asked", + properties: { + id: "per_unrelated", + sessionID: "ses_unrelated_child", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }, + { + id: "evt-unrelated-question", + type: "question.asked", + properties: { + id: "que_unrelated", + sessionID: "ses_unrelated_child", + questions: [], + }, + }, { type: "message.updated", properties: { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 4ff03ecff1..0c31eed970 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -15,13 +15,17 @@ import { import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -176,6 +180,77 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +type OpenCodeSessionStatusEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "session.status" } +>; + +const OpenCodeSessionStatusMap = Schema.Record( + Schema.String, + Schema.Struct({ type: Schema.String }), +); +const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap); + +interface OpenCodeCancellation { + readonly turnId: TurnId | undefined; + readonly completion: Deferred.Deferred; + acknowledged?: boolean; + deferredIdleEvent?: OpenCodeSessionStatusEvent; +} + +interface OpenCodeIdleReconciliation { + readonly turnId: TurnId; + readonly promptGeneration: number; + raw: unknown; + warned: boolean; + dirty: boolean; + fiber?: Fiber.Fiber; +} + +interface OpenCodePromptAdmission { + readonly generation: number; + readonly turnId: TurnId; + readonly messageId: string; + readonly priorAwaitingBusy: boolean; + readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined; + idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; + idleObservedAfterMessage: boolean; + messageObserved: boolean; + busyObserved: boolean; + idleStatusConfirmations: number; + accepted: boolean; + cancelled: boolean; + readonly acceptance: Deferred.Deferred; + readonly submissionSettled: Deferred.Deferred; + promptFiber?: Fiber.Fiber; + recoveryFiber?: Fiber.Fiber; + recoveryRaw: unknown; +} + +type OpenCodeTerminalRequestEvent = Extract< + OpenCodeSubscribedEvent, + { + readonly type: "permission.replied" | "question.replied" | "question.rejected"; + } +>; + +type OpenCodeAskedRequestEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "permission.asked" | "question.asked" } +>; + +type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent; + +interface OpenCodeRequestRelationRetry { + warned: boolean; + fiber?: Fiber.Fiber; +} + +interface OpenCodePendingRequestRecovery { + warned: boolean; + rerun: boolean; +} + function trimText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -215,6 +290,28 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return title; } +function isOpenCodeAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "MessageAbortedError" + ); +} + +function isOpenCodeChildRequestEvent(event: OpenCodeSubscribedEvent): boolean { + switch (event.type) { + case "permission.asked": + case "permission.replied": + case "question.asked": + case "question.replied": + case "question.rejected": + return true; + default: + return false; + } +} + const OPENCODE_DEFAULT_TITLE_PATTERN = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -228,6 +325,10 @@ interface OpenCodeSessionContext { readonly server: OpenCodeServerConnection; readonly directory: string; readonly openCodeSessionId: string; + readonly relatedSessionIds: Set; + readonly resolvedRequestIds: Set; + readonly emittedTerminalRequestIds: Set; + readonly requestRelationRetries: Map; readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; @@ -238,6 +339,16 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + cancellation: OpenCodeCancellation | undefined; + interruptedTurnId: TurnId | undefined; + reconcileIdleStatus: boolean; + awaitingBusyAfterInterruption: boolean; + pendingIdleReconciliation: OpenCodeIdleReconciliation | undefined; + pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined; + promptGeneration: number; + promptAdmission: OpenCodePromptAdmission | undefined; + readonly promptSemaphore: Semaphore.Semaphore; + readonly firstConnection: Deferred.Deferred; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -538,24 +649,99 @@ function updateProviderSession( }, ): Effect.Effect { return Effect.gen(function* () { - const updatedAt = yield* nowIso; - const nextSession = { - ...context.session, - ...patch, - updatedAt, - } as ProviderSession & Record; - const mutableSession = nextSession as Record; - if (options?.clearActiveTurnId) { - delete mutableSession.activeTurnId; - } - if (options?.clearLastError) { - delete mutableSession.lastError; - } - context.session = nextSession; - return nextSession; + return applyProviderSessionUpdate(context, patch, options, yield* nowIso); }); } +function applyProviderSessionUpdate( + context: OpenCodeSessionContext, + patch: Partial, + options: + | { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + } + | undefined, + updatedAt: string, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt, + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellation")(function* ( + context: OpenCodeSessionContext, + detail: string, +) { + const cancellation = context.cancellation; + if (!cancellation) { + return; + } + context.cancellation = undefined; + yield* Deferred.fail( + cancellation.completion, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail, + }), + ).pipe(Effect.ignore); +}); + +const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) => + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + +const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( + context: OpenCodeSessionContext, +) { + const admission = context.promptAdmission; + if (!admission) { + return; + } + admission.cancelled = true; + if (admission.promptFiber) { + yield* Fiber.interrupt(admission.promptFiber); + } + yield* Deferred.await(admission.submissionSettled); +}); + +const closeStartingOpenCodeContext = Effect.fn("closeStartingOpenCodeContext")(function* ( + context: OpenCodeSessionContext, + abortRemote: boolean, +) { + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session startup ended before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPendingOpenCodePrompt(context); + yield* failPendingOpenCodeCancellation(context, "OpenCode session startup was cancelled."); + context.promptAdmission = undefined; + if (abortRemote) { + yield* abortOpenCodeSessionForTeardown(context); + } + yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore); +}); + const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, ) { @@ -563,13 +749,26 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( if (yield* Ref.getAndSet(context.stopped, true)) { return false; } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session stopped before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPendingOpenCodePrompt(context); + const cancellation = context.cancellation; + context.cancellation = undefined; + if (cancellation) { + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + } + context.promptAdmission = undefined; // Best-effort remote abort. The scope close below tears down the local // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + yield* abortOpenCodeSessionForTeardown(context); // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered — the `AbortController.abort()` call, @@ -604,6 +803,24 @@ export function makeOpenCodeAdapter( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); + const deleteContextIfCurrent = (context: OpenCodeSessionContext) => { + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + }; + const awaitOpenCodeContextReady = Effect.fn("awaitOpenCodeContextReady")(function* ( + context: OpenCodeSessionContext, + ) { + yield* Deferred.await(context.firstConnection); + const current = yield* ensureSessionContext(sessions, context.session.threadId); + if (current !== context) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: context.session.threadId, + }); + } + return current; + }); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -615,6 +832,37 @@ export function makeOpenCodeAdapter( }), ), ); + let messageIdEpochMillis = -1; + let messageIdCounter = 0; + // T3 supplies the message ID to match prompt admission events. Keep OpenCode's sortable native shape so equal-time messages retain their upstream order. + const makeOpenCodeMessageId = Effect.fn("makeOpenCodeMessageId")(function* () { + const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); + if (epochMillis !== messageIdEpochMillis) { + messageIdEpochMillis = epochMillis; + messageIdCounter = 0; + } + messageIdCounter += 1; + const encodedTime = BigInt.asUintN( + 48, + BigInt(epochMillis) * 0x1000n + BigInt(messageIdCounter), + ) + .toString(16) + .padStart(12, "0"); + const randomBytes = yield* crypto.randomBytes(14).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomBytes", + detail: "Failed to generate an OpenCode message identifier.", + cause, + }), + ), + ); + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const random = Array.from(randomBytes, (byte) => alphabet[byte % alphabet.length]).join(""); + return `msg_${encodedTime}${random}`; + }); const buildEventBase = (input: EventBaseInput) => Effect.all({ eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), @@ -684,6 +932,424 @@ export function makeOpenCodeAdapter( }, ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* ( + context: OpenCodeSessionContext, + ) { + const pending = context.pendingIdleReconciliation; + context.pendingIdleReconciliation = undefined; + if (pending?.fiber) { + yield* Fiber.interrupt(pending.fiber); + } + }); + + const completeOpenCodeTurn = Effect.fn("completeOpenCodeTurn")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + promptGeneration: number, + raw: unknown, + ) { + const updatedAt = yield* nowIso; + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.activeTurnId !== turnId || + context.promptGeneration !== promptGeneration || + context.cancellation?.turnId === turnId + ) { + return; + } + const pendingIdleReconciliation = context.pendingIdleReconciliation; + if ( + pendingIdleReconciliation?.turnId === turnId && + pendingIdleReconciliation.promptGeneration === promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + } + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.interruptedTurnId = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + applyProviderSessionUpdate( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + updatedAt, + ); + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + }); + + const scheduleIdleReconciliation = Effect.fn("scheduleIdleReconciliation")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + raw: unknown, + ) { + const existing = context.pendingIdleReconciliation; + if (existing?.turnId === turnId && existing.promptGeneration === context.promptGeneration) { + existing.raw = raw; + existing.dirty = true; + return; + } + yield* cancelIdleReconciliation(context); + + const pending: OpenCodeIdleReconciliation = { + turnId, + promptGeneration: context.promptGeneration, + raw, + warned: false, + dirty: false, + }; + context.pendingIdleReconciliation = pending; + const reconcile = Effect.gen(function* () { + let retryCount = 0; + while (context.pendingIdleReconciliation === pending) { + if ( + context.activeTurnId !== turnId || + context.awaitingBusyAfterInterruption || + context.promptGeneration !== pending.promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + return; + } + const result = yield* runOpenCodeSdk("session.status", (signal) => + context.client.session.status(undefined, { signal }), + ).pipe( + Effect.timeout("1 second"), + Effect.retry({ times: 1 }), + Effect.match({ + onFailure: (cause) => ({ type: "unknown" as const, cause }), + onSuccess: (response) => { + const data = Option.getOrUndefined(decodeOpenCodeSessionStatusMap(response.data)); + if (data === undefined) { + return { type: "unknown" as const, cause: undefined }; + } + const status = data[context.openCodeSessionId]; + if (status === undefined || status.type === "idle") { + return { type: "idle" as const }; + } + if (status.type === "busy" || status.type === "retry") { + return { type: "busy" as const }; + } + return { type: "unknown" as const, cause: undefined }; + }, + }), + ); + + if ( + context.pendingIdleReconciliation !== pending || + context.activeTurnId !== turnId || + context.promptGeneration !== pending.promptGeneration + ) { + return; + } + if (result.type === "idle") { + context.pendingIdleReconciliation = undefined; + yield* completeOpenCodeTurn(context, turnId, pending.promptGeneration, pending.raw); + return; + } + if (result.type === "busy") { + if (pending.dirty) { + pending.dirty = false; + continue; + } + context.pendingIdleReconciliation = undefined; + return; + } + if (!pending.warned) { + pending.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId })), + type: "runtime.warning", + payload: { + message: "OpenCode turn completion is waiting for session status.", + detail: + result.cause === undefined + ? "session.status returned missing or invalid status data." + : openCodeRuntimeErrorDetail(result.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.pendingIdleReconciliation === pending) { + context.pendingIdleReconciliation = undefined; + } + }), + ), + ); + pending.fiber = yield* reconcile.pipe(Effect.forkIn(context.sessionScope)); + }); + + const failPromptAdmissionRecovery = Effect.fn("failPromptAdmissionRecovery")(function* ( + context: OpenCodeSessionContext, + promptAdmission: OpenCodePromptAdmission, + ) { + if ( + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation + ) { + return; + } + const detail = + "OpenCode accepted the prompt, but T3 Code could not confirm its message or session status."; + const abortExit = yield* Effect.exit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe(Effect.timeout("1 second")), + ); + if (Exit.isFailure(abortExit)) { + yield* emitUnexpectedExit( + context, + `${detail} The cleanup abort also failed: ${openCodeRuntimeErrorDetail(Cause.squash(abortExit.cause))}`, + ); + deleteContextIfCurrent(context); + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { status: "error", lastError: detail }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: promptAdmission.turnId, + raw: promptAdmission.recoveryRaw, + })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: detail, + }, + }); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: promptAdmission.turnId, + raw: promptAdmission.recoveryRaw, + })), + type: "runtime.error", + payload: { + message: detail, + class: "transport_error", + }, + }); + }); + + const schedulePromptAdmissionRecovery = Effect.fn("schedulePromptAdmissionRecovery")(function* ( + context: OpenCodeSessionContext, + raw: unknown, + ) { + const promptAdmission = context.promptAdmission; + if (!promptAdmission || promptAdmission.cancelled) { + return; + } + if (raw !== undefined) { + promptAdmission.recoveryRaw = raw; + } + if (promptAdmission.recoveryFiber) { + return; + } + const recover = Effect.gen(function* () { + yield* Deferred.await(promptAdmission.acceptance); + for (let retryCount = 0; retryCount < 5; retryCount += 1) { + if ( + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled || + (yield* Ref.get(context.stopped)) + ) { + return; + } + + if (!promptAdmission.messageObserved) { + const response = yield* runOpenCodeSdk("session.message", (signal) => + context.client.session.message( + { + sessionID: context.openCodeSessionId, + messageID: promptAdmission.messageId, + }, + { signal }, + ), + ).pipe(Effect.timeout("1 second"), Effect.option); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + sessions.get(context.session.threadId) !== context || + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled + ) { + return; + } + const message = Option.isSome(response) ? response.value.data : undefined; + if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { + promptAdmission.messageObserved = true; + context.messageRoleById.set(promptAdmission.messageId, "user"); + } + } + + const statusResponse = yield* runOpenCodeSdk("session.status", (signal) => + context.client.session.status(undefined, { signal }), + ).pipe(Effect.timeout("1 second"), Effect.option); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + sessions.get(context.session.threadId) !== context || + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled + ) { + return; + } + const statusData = Option.isSome(statusResponse) + ? Option.getOrUndefined(decodeOpenCodeSessionStatusMap(statusResponse.value.data)) + : undefined; + const status = statusData?.[context.openCodeSessionId]; + const isIdle = + statusData !== undefined && (status === undefined || status.type === "idle"); + const isBusy = status?.type === "busy" || status?.type === "retry"; + if (isBusy) { + promptAdmission.busyObserved = true; + promptAdmission.idleStatusConfirmations = 0; + context.awaitingBusyAfterInterruption = false; + context.promptAdmission = undefined; + return; + } + + const idle = promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle; + if ( + isIdle && + idle !== undefined && + (promptAdmission.messageObserved || promptAdmission.busyObserved) + ) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* scheduleIdleReconciliation(context, promptAdmission.turnId, idle.raw); + return; + } + if (isIdle && promptAdmission.messageObserved) { + promptAdmission.idleStatusConfirmations += 1; + if (promptAdmission.idleStatusConfirmations >= 2) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* completeOpenCodeTurn( + context, + promptAdmission.turnId, + promptAdmission.generation, + { + type: "session.status.recovered", + status: statusData, + }, + ); + return; + } + } else if (!isIdle) { + promptAdmission.idleStatusConfirmations = 0; + } + if ( + isIdle && + promptAdmission.messageObserved && + promptAdmission.recoveryRaw !== undefined + ) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* scheduleIdleReconciliation( + context, + promptAdmission.turnId, + promptAdmission.recoveryRaw, + ); + return; + } + + const delayMs = Math.min(250 * 2 ** retryCount, 2_000); + yield* Effect.sleep(`${delayMs} millis`); + } + yield* failPromptAdmissionRecovery(context, promptAdmission); + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + delete promptAdmission.recoveryFiber; + }), + ), + ); + promptAdmission.recoveryFiber = yield* recover.pipe(Effect.forkIn(context.sessionScope)); + }); + + const interruptOpenCodeTurn = Effect.fn("interruptOpenCodeTurn")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + raw?: unknown, + ) { + if (context.interruptedTurnId === turnId) { + return; + } + yield* cancelIdleReconciliation(context); + context.interruptedTurnId = turnId; + context.reconcileIdleStatus = true; + context.awaitingBusyAfterInterruption = false; + const cancellation = + context.cancellation?.turnId === turnId ? context.cancellation : undefined; + if (cancellation) { + context.cancellation = undefined; + } + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true, clearLastError: true }, + ); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + if (cancellation) { + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + } + }); + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( context: OpenCodeSessionContext, message: string, @@ -695,8 +1361,21 @@ export function makeOpenCodeAdapter( if (yield* Ref.getAndSet(context.stopped, true)) { return; } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session exited before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* failPendingOpenCodeCancellation( + context, + "OpenCode session exited during cancellation.", + ); + context.promptAdmission = undefined; const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); + deleteContextIfCurrent(context); // Emit lifecycle events BEFORE tearing down the scope. Both call sites // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; // closing that scope triggers the fiber-interrupt finalizer, so any @@ -727,9 +1406,7 @@ export function makeOpenCodeAdapter( // Inline the teardown that `stopOpenCodeContext` would do; we can't // delegate to it because our `getAndSet` above already flipped the // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + yield* abortOpenCodeSessionForTeardown(context); yield* Scope.close(context.sessionScope, Exit.void); }); @@ -800,35 +1477,447 @@ export function makeOpenCodeAdapter( } }); - const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* ( context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, + candidateSessionId: string, ) { - const payloadSessionId = openCodeEventSessionId(event); - if (payloadSessionId !== context.openCodeSessionId) { - return; + if (context.relatedSessionIds.has(candidateSessionId)) { + return true; } - const turnId = context.activeTurnId; - yield* writeNativeEventBestEffort(context.session.threadId, { - observedAt: yield* nowIso, - event: { - provider: PROVIDER, - threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, - type: event.type, - ...(turnId ? { turnId } : {}), - payload: event, - }, - }); + const seen = new Set(); + const getSession = (sessionID: string) => + runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.succeed(undefined), + ), + ); + let sessionId: string | undefined = candidateSessionId; + for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) { + if (context.relatedSessionIds.has(sessionId)) { + context.relatedSessionIds.add(candidateSessionId); + return true; + } + if (seen.has(sessionId)) { + return false; + } + seen.add(sessionId); + const currentSessionId: string = sessionId; + const response = yield* getSession(currentSessionId); + if (response === undefined) { + return false; + } + if (!response.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.get", + detail: `OpenCode session.get returned no session payload for '${currentSessionId}'.`, + }); + } + sessionId = response.data.parentID; + } + return false; + }); - switch (event.type) { - case "session.updated": { - const title = openCodeEventSessionTitle(event); - if (title) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeAskedRequestEvent, + raw: unknown, + ) { + if (context.resolvedRequestIds.has(event.properties.id)) { + return; + } + if (event.type === "permission.asked") { + const request = event.properties; + if (context.pendingPermissions.has(request.id)) { + return; + } + context.pendingPermissions.set(request.id, request); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + })), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(request.permission), + detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission, + args: request.metadata, + }, + }); + return; + } + + const request = event.properties; + if (context.pendingQuestions.has(request.id)) { + return; + } + context.pendingQuestions.set(request.id, request); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + })), + type: "user-input.requested", + payload: { questions: normalizeQuestionRequest(request) }, + }); + }); + + const resolvePendingOpenCodeRequest = Effect.fn("resolvePendingOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + requestId: string, + ) { + context.resolvedRequestIds.add(requestId); + const retry = context.requestRelationRetries.get(requestId); + context.requestRelationRetries.delete(requestId); + if (retry?.fiber) { + yield* Fiber.interrupt(retry.fiber); + } + }); + + const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeTerminalRequestEvent, + ) { + const requestId = event.properties.requestID; + if (context.emittedTerminalRequestIds.has(requestId)) { + return; + } + context.emittedTerminalRequestIds.add(requestId); + if (event.type === "permission.replied") { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + return; + } + + const request = context.pendingQuestions.get(requestId); + const answers = + event.type === "question.replied" && request + ? Object.fromEntries( + request.questions.map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ) + : {}; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw: event, + })), + type: "user-input.resolved", + payload: { answers }, + }); + }); + + const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeRoutedRequestEvent, + raw: unknown = event, + ) { + const isAskedEvent = event.type === "permission.asked" || event.type === "question.asked"; + const requestId = isAskedEvent ? event.properties.id : event.properties.requestID; + if (context.requestRelationRetries.has(requestId)) { + return; + } + if (isAskedEvent && context.resolvedRequestIds.has(requestId)) { + return; + } + const retry: OpenCodeRequestRelationRetry = { warned: false }; + context.requestRelationRetries.set(requestId, retry); + const run = Effect.gen(function* () { + let retryCount = 0; + while (context.requestRelationRetries.get(requestId) === retry) { + const relation = yield* isRelatedOpenCodeSession( + context, + event.properties.sessionID, + ).pipe( + Effect.match({ + onFailure: (cause) => ({ type: "unknown" as const, cause }), + onSuccess: (related) => ({ type: "known" as const, related }), + }), + ); + if (context.requestRelationRetries.get(requestId) !== retry) { + return; + } + if (relation.type === "known") { + context.requestRelationRetries.delete(requestId); + if (relation.related) { + if (isAskedEvent) { + yield* emitPendingOpenCodeRequest(context, event, raw); + } else { + yield* emitTerminalOpenCodeRequest(context, event); + } + } + return; + } + if (!retry.warned) { + retry.warned = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + requestId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode request routing is waiting for session ancestry.", + detail: openCodeRuntimeErrorDetail(relation.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + if (!isAskedEvent && retryCount >= 5) { + return; + } + yield* Effect.sleep(`${delayMs} millis`); + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.requestRelationRetries.get(requestId) === retry) { + context.requestRelationRetries.delete(requestId); + } + }), + ), + ); + retry.fiber = yield* run.pipe(Effect.forkIn(context.sessionScope)); + }); + + const schedulePendingRequestRecovery = Effect.fn("schedulePendingRequestRecovery")(function* ( + context: OpenCodeSessionContext, + ) { + if (context.pendingRequestRecovery) { + context.pendingRequestRecovery.rerun = true; + return; + } + const recovery: OpenCodePendingRequestRecovery = { warned: false, rerun: false }; + context.pendingRequestRecovery = recovery; + const run = Effect.gen(function* () { + let retryCount = 0; + while (context.pendingRequestRecovery === recovery) { + const responses = yield* Effect.all({ + permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()), + questions: runOpenCodeSdk("question.list", () => context.client.question.list()), + }).pipe( + Effect.match({ + onFailure: (cause) => ({ type: "failure" as const, cause }), + onSuccess: (value) => ({ type: "success" as const, value }), + }), + ); + if (context.pendingRequestRecovery !== recovery) { + return; + } + if (responses.type === "failure") { + if (!recovery.warned) { + recovery.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.warning", + payload: { + message: "OpenCode pending request recovery failed and will retry.", + detail: openCodeRuntimeErrorDetail(responses.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + continue; + } + const permissions = responses.value.permissions.data; + const questions = responses.value.questions.data; + if (permissions === undefined || questions === undefined) { + if (!recovery.warned) { + recovery.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.warning", + payload: { + message: "OpenCode pending request recovery returned no data and will retry.", + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + continue; + } + yield* Effect.forEach( + permissions, + (request) => + scheduleRequestRelationRetry( + context, + { id: `recovered:${request.id}`, type: "permission.asked", properties: request }, + { type: "permission.asked", properties: request, recovered: true }, + ), + { discard: true }, + ); + yield* Effect.forEach( + questions, + (request) => + scheduleRequestRelationRetry( + context, + { id: `recovered:${request.id}`, type: "question.asked", properties: request }, + { type: "question.asked", properties: request, recovered: true }, + ), + { discard: true }, + ); + if (recovery.rerun) { + recovery.rerun = false; + recovery.warned = false; + continue; + } + context.pendingRequestRecovery = undefined; + return; + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.pendingRequestRecovery === recovery) { + context.pendingRequestRecovery = undefined; + } + }), + ), + ); + yield* run.pipe(Effect.forkIn(context.sessionScope)); + }); + + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + if (event.type === "server.connected") { + if ( + (yield* Ref.get(context.stopped)) || + sessions.get(context.session.threadId) !== context + ) { + return; + } + const isFirstConnection = !(yield* Deferred.isDone(context.firstConnection)); + if (isFirstConnection) { + const updatedAt = yield* nowIso; + if ( + (yield* Ref.get(context.stopped)) || + sessions.get(context.session.threadId) !== context + ) { + return; + } + applyProviderSessionUpdate(context, { status: "ready" }, undefined, updatedAt); + if (!(yield* Deferred.succeed(context.firstConnection, undefined))) { + return; + } + } + yield* schedulePendingRequestRecovery(context); + if (!isFirstConnection) { + yield* schedulePromptAdmissionRecovery(context, event); + } + return; + } + const terminalRequestId = + event.type === "permission.replied" || + event.type === "question.replied" || + event.type === "question.rejected" + ? event.properties.requestID + : undefined; + if (terminalRequestId !== undefined) { + yield* resolvePendingOpenCodeRequest(context, terminalRequestId); + } + if (event.type === "session.created" || event.type === "session.updated") { + const session = event.properties.info; + if (session.parentID && context.relatedSessionIds.has(session.parentID)) { + context.relatedSessionIds.add(session.id); + } + } else if (event.type === "session.deleted") { + context.relatedSessionIds.delete(event.properties.info.id); + } + + const payloadSessionId = openCodeEventSessionId(event); + const isParentEvent = payloadSessionId === context.openCodeSessionId; + let isKnownPendingTerminalEvent = false; + if ( + payloadSessionId !== undefined && + !context.relatedSessionIds.has(payloadSessionId) && + isOpenCodeChildRequestEvent(event) + ) { + if (event.type === "permission.asked") { + yield* scheduleRequestRelationRetry(context, event); + } else if (event.type === "question.asked") { + yield* scheduleRequestRelationRetry(context, event); + } else if ( + event.type === "permission.replied" || + event.type === "question.replied" || + event.type === "question.rejected" + ) { + const requestId = event.properties.requestID; + isKnownPendingTerminalEvent = + context.pendingPermissions.has(requestId) || context.pendingQuestions.has(requestId); + if (!isKnownPendingTerminalEvent) { + yield* scheduleRequestRelationRetry(context, event); + return; + } + } + } + const isChildRequestEvent = + payloadSessionId !== undefined && + isOpenCodeChildRequestEvent(event) && + (context.relatedSessionIds.has(payloadSessionId) || isKnownPendingTerminalEvent); + if (!isParentEvent && !isChildRequestEvent) { + return; + } + + const turnId = context.activeTurnId; + yield* writeNativeEventBestEffort(context.session.threadId, { + observedAt: yield* nowIso, + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + ...(!isParentEvent && payloadSessionId ? { childSessionId: payloadSessionId } : {}), + payload: event, + }, + }); + + const suppressInterruptedParentOutput = + isParentEvent && + ((context.activeTurnId === undefined && + (context.interruptedTurnId !== undefined || context.reconcileIdleStatus)) || + context.awaitingBusyAfterInterruption) && + (event.type === "message.part.delta" || + event.type === "message.part.updated" || + (event.type === "message.updated" && event.properties.info.role === "assistant")); + if (suppressInterruptedParentOutput) { + return; + } + + switch (event.type) { + case "session.updated": { + const title = openCodeEventSessionTitle(event); + if (title) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, raw: event, })), type: "thread.metadata.updated", @@ -844,6 +1933,24 @@ export function makeOpenCodeAdapter( } case "message.updated": { + const promptAdmission = context.promptAdmission; + if ( + event.properties.info.role === "user" && + promptAdmission?.messageId === event.properties.info.id + ) { + promptAdmission.messageObserved = true; + if (promptAdmission.accepted) { + const idle = promptAdmission.idleDuringAdmission; + context.awaitingBusyAfterInterruption = false; + context.promptAdmission = undefined; + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + if (idle) { + yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw); + } + } + } context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { for (const part of context.partById.values()) { @@ -957,101 +2064,44 @@ export function makeOpenCodeAdapter( } case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - })), - type: "request.opened", - payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, - }, - }); + yield* emitPendingOpenCodeRequest(context, event, event); break; } case "permission.replied": { context.pendingPermissions.delete(event.properties.requestID); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "request.resolved", - payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), - }, - }); + yield* emitTerminalOpenCodeRequest(context, event); break; } case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - })), - type: "user-input.requested", - payload: { - questions: normalizeQuestionRequest(event.properties), - }, - }); + yield* emitPendingOpenCodeRequest(context, event, event); break; } case "question.replied": { - const request = context.pendingQuestions.get(event.properties.requestID); + yield* emitTerminalOpenCodeRequest(context, event); context.pendingQuestions.delete(event.properties.requestID); - const answers = Object.fromEntries( - (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), - event.properties.answers[index]?.join(", ") ?? "", - ]), - ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "user-input.resolved", - payload: { answers }, - }); break; } case "question.rejected": { context.pendingQuestions.delete(event.properties.requestID); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "user-input.resolved", - payload: { answers: {} }, - }); + yield* emitTerminalOpenCodeRequest(context, event); break; } case "session.status": { if (event.properties.status.type === "busy") { + if (turnId === undefined) { + break; + } + yield* cancelIdleReconciliation(context); + context.awaitingBusyAfterInterruption = false; + if (context.promptAdmission?.turnId === turnId) { + context.promptAdmission.busyObserved = true; + yield* schedulePromptAdmissionRecovery(context, event); + } yield* updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -1075,19 +2125,25 @@ export function makeOpenCodeAdapter( } if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "completed", - }, - }); + if (context.cancellation?.turnId === turnId) { + context.cancellation.deferredIdleEvent = event; + break; + } + if (context.promptAdmission?.turnId === turnId) { + context.promptAdmission.idleDuringAdmission = { turnId, raw: event }; + context.promptAdmission.idleObservedAfterMessage = + context.promptAdmission.messageObserved; + yield* schedulePromptAdmissionRecovery(context, event); + break; + } + if (context.awaitingBusyAfterInterruption) { + break; + } + if (context.reconcileIdleStatus) { + yield* scheduleIdleReconciliation(context, turnId, event); + break; + } + yield* completeOpenCodeTurn(context, turnId, context.promptGeneration, event); } break; } @@ -1095,7 +2151,32 @@ export function makeOpenCodeAdapter( case "session.error": { const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; + const cancellation = context.cancellation; + if (isOpenCodeAbortError(event.properties.error)) { + if (cancellation !== undefined && cancellation.turnId === undefined) { + cancellation.acknowledged = true; + context.cancellation = undefined; + context.reconcileIdleStatus = true; + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + break; + } + if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { + yield* interruptOpenCodeTurn(context, activeTurnId, event); + break; + } + if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) { + break; + } + } + yield* cancelIdleReconciliation(context); + if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { + context.cancellation = undefined; + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + } context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.reconcileIdleStatus = false; yield* updateProviderSession( context, { @@ -1211,8 +2292,11 @@ export function makeOpenCodeAdapter( const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { + if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) { + return (yield* awaitOpenCodeContextReady(existing)).session; + } yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); + deleteContextIfCurrent(existing); } const started = yield* Effect.gen(function* () { @@ -1224,13 +2308,15 @@ export function makeOpenCodeAdapter( // process automatically. No manual `server.close()` needed. const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, + directory, serverUrl, + ...(serverPassword ? { serverPassword } : {}), environment: mergeProviderSessionEnvironment(options?.environment, input.env), }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, - ...(server.external && serverPassword ? { serverPassword } : {}), + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { @@ -1349,29 +2435,11 @@ export function makeOpenCodeAdapter( return startedExit.value; }); - // Guard against a concurrent startSession call that may have raced - // and already inserted a session while we were awaiting async work. - const raceWinner = sessions.get(input.threadId); - if (raceWinner) { - // Another call won the race — clean up. Only abort the remote - // session if we created it here; a resumed one is shared upstream - // state the winner is now using. - if (started.created) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); - } - yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); - return raceWinner.session; - } - const createdAt = yield* nowIso; const session: ProviderSession = { provider: PROVIDER, providerInstanceId: boundInstanceId, - status: "ready", + status: "connecting", runtimeMode: input.runtimeMode, cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), @@ -1393,6 +2461,10 @@ export function makeOpenCodeAdapter( server: started.server, directory, openCodeSessionId: started.openCodeSession.id, + relatedSessionIds: new Set([started.openCodeSession.id]), + resolvedRequestIds: new Set(), + emittedTerminalRequestIds: new Set(), + requestRelationRetries: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), partById: new Map(), @@ -1403,11 +2475,56 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + cancellation: undefined, + interruptedTurnId: undefined, + reconcileIdleStatus: false, + awaitingBusyAfterInterruption: false, + pendingIdleReconciliation: undefined, + pendingRequestRecovery: undefined, + promptGeneration: 0, + promptAdmission: undefined, + promptSemaphore: Semaphore.makeUnsafe(1), + firstConnection: Deferred.makeUnsafe(), stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another start published first. A newly created remote session + // belongs to this loser; a resumed session is shared upstream state. + yield* closeStartingOpenCodeContext(context, started.created); + return (yield* awaitOpenCodeContextReady(raceWinner)).session; + } sessions.set(input.threadId, context); - yield* startEventPump(context); + const cleanupStartingContext = closeStartingOpenCodeContext(context, started.created).pipe( + Effect.ensuring(Effect.sync(() => deleteContextIfCurrent(context))), + ); + const connectionExit = yield* Effect.gen(function* () { + yield* startEventPump(context); + yield* Deferred.await(context.firstConnection).pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode event stream did not connect within 10 seconds.", + cause, + }), + ), + ); + }).pipe( + Effect.onInterrupt(() => cleanupStartingContext), + Effect.exit, + ); + if (Exit.isFailure(connectionExit)) { + yield* cleanupStartingContext; + return yield* Effect.failCause(connectionExit.cause); + } + yield* awaitOpenCodeContextReady(context); + if (!started.created) { + yield* schedulePendingRequestRecovery(context); + } yield* emit({ ...(yield* buildEventBase({ threadId: input.threadId })), @@ -1424,17 +2541,13 @@ export function makeOpenCodeAdapter( }, }); - return session; + return context.session; }, ); const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* ensureSessionContext(sessions, input.threadId); - // A sendTurn while a turn is active is a steer: OpenCode queues the - // prompt into the busy session and the work continues as one turn, so - // the active turn id is reused instead of opening a new turn. - const steeringTurnId = context.activeTurnId; - const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); + yield* awaitOpenCodeContextReady(context); const modelSelection = input.modelSelection ?? (context.session.model @@ -1457,6 +2570,8 @@ export function makeOpenCodeAdapter( } const text = input.input?.trim(); + // OpenCode ingests images, text, and PDFs natively; formats its model + // paths reject ride only as the prompt's file path line. const fileParts = toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => @@ -1473,107 +2588,411 @@ export function makeOpenCodeAdapter( }); } - const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + return yield* context.promptSemaphore.withPermit( + Effect.gen(function* () { + const freshTurnId = TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); + const messageId = yield* makeOpenCodeMessageId(); + const pendingCancellation = context.cancellation; + if (pendingCancellation) { + const cancellationResult = yield* Deferred.await(pendingCancellation.completion).pipe( + Effect.result, + ); + if ((yield* Ref.get(context.stopped)) || sessions.get(input.threadId) !== context) { + return yield* Effect.interrupt; + } + if (cancellationResult._tag === "Failure") { + return yield* cancellationResult.failure; + } + } + if (sessions.get(input.threadId) !== context || (yield* Ref.get(context.stopped))) { + return yield* Effect.interrupt; + } + // A sendTurn while a turn is active is a steer. OpenCode queues the + // prompt into the running session, so the active turn id is reused. + const steeringTurnId = context.activeTurnId; + const turnId = steeringTurnId ?? freshTurnId; + const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + const pendingIdleReconciliation = context.pendingIdleReconciliation; + const priorAwaitingBusy = context.awaitingBusyAfterInterruption; + const priorIdleCandidate = pendingIdleReconciliation + ? { + turnId: pendingIdleReconciliation.turnId, + raw: pendingIdleReconciliation.raw, + } + : undefined; + context.pendingIdleReconciliation = undefined; + const promptGeneration = context.promptGeneration + 1; + const promptAdmission: OpenCodePromptAdmission = { + generation: promptGeneration, + turnId, + messageId, + priorAwaitingBusy, + priorIdle: priorIdleCandidate, + idleDuringAdmission: undefined, + idleObservedAfterMessage: false, + messageObserved: false, + busyObserved: false, + idleStatusConfirmations: 0, + accepted: false, + cancelled: false, + acceptance: Deferred.makeUnsafe(), + submissionSettled: Deferred.makeUnsafe(), + recoveryRaw: undefined, + }; + context.promptGeneration = promptGeneration; + context.promptAdmission = promptAdmission; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + if (steeringTurnId === undefined) { + context.awaitingBusyAfterInterruption = context.interruptedTurnId !== undefined; + } + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + yield* updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); - context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; - yield* updateProviderSession( - context, - { - status: "running", - activeTurnId: turnId, - model: modelSelection?.model ?? context.session.model, - }, - { clearLastError: true }, - ); + if (steeringTurnId === undefined) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + } - if (steeringTurnId === undefined) { - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), - }, - }); - } + if (promptAdmission.cancelled || (yield* Ref.get(context.stopped))) { + yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( + Effect.ignore, + ); + const cancellation = context.cancellation; + if (cancellation?.turnId === turnId) { + yield* Deferred.await(cancellation.completion).pipe(Effect.result); + } + return yield* Effect.interrupt; + } - yield* runOpenCodeSdk("session.promptAsync", () => - context.client.session.promptAsync({ - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], - }), - ).pipe( - Effect.mapError(toRequestError), - // On failure of a fresh turn: clear active-turn state, flip the - // session back to ready with lastError set, emit turn.aborted, then - // let the typed error propagate. We don't need to rebuild the error - // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. - Effect.tapError((requestError) => - steeringTurnId !== undefined - ? Effect.void - : Effect.gen(function* () { - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - yield* updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, + let promptTimedOut = false; + const promptEffect = runOpenCodeSdk("session.promptAsync", (signal) => + context.client.session.promptAsync( + { + sessionID: context.openCodeSessionId, + messageID: messageId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }, + { signal }, + ), + ).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => { + promptTimedOut = true; + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: "OpenCode prompt submission did not complete within 10 seconds.", + cause, + }), ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: input.threadId, - turnId, - })), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, - }); + }, + }), + Effect.tapError((requestError) => + context.promptAdmission !== promptAdmission || context.activeTurnId !== turnId + ? Effect.void + : Effect.gen(function* () { + if (!promptTimedOut) { + if (steeringTurnId !== undefined) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = promptAdmission.priorAwaitingBusy; + const idle = + promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle; + if (idle) { + yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw); + } + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { reason: requestError.detail }, + }); + return; + } + const cleanupExit = yield* Effect.exit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort( + { sessionID: context.openCodeSessionId }, + { signal }, + ), + ).pipe(Effect.timeout("1 second")), + ); + if (Exit.isFailure(cleanupExit)) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "runtime.warning", + payload: { + message: + "OpenCode prompt submission failed and its cleanup abort did not complete.", + detail: openCodeRuntimeErrorDetail(Cause.squash(cleanupExit.cause)), + }, + }); + yield* schedulePromptAdmissionRecovery(context, { + requestError, + cleanupError: Cause.squash(cleanupExit.cause), + }); + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: input.threadId, + turnId, + })), + type: "turn.aborted", + payload: { + reason: requestError.detail, + }, + }); + }), + ), + Effect.onExit((exit) => + Effect.gen(function* () { + yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( + Effect.ignore, + ); + if (Exit.isFailure(exit)) { + yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe( + Effect.ignore, + ); + } }), - ), - ); + ), + Effect.asVoid, + ); + const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope)); + promptAdmission.promptFiber = promptFiber; + const promptExit = yield* Effect.exit(Fiber.join(promptFiber)); + delete promptAdmission.promptFiber; + + const intentionallyCancelled = + promptAdmission.cancelled || + (yield* Ref.get(context.stopped)) || + sessions.get(input.threadId) !== context; + if (Exit.isFailure(promptExit) && !intentionallyCancelled) { + return yield* Effect.failCause(promptExit.cause); + } + const cancelled = + intentionallyCancelled || + context.activeTurnId !== turnId || + context.promptGeneration !== promptAdmission.generation; + if (cancelled) { + const cancellation = context.cancellation; + if (cancellation?.turnId === turnId) { + yield* Deferred.await(cancellation.completion).pipe(Effect.result); + } + if (context.promptAdmission === promptAdmission) { + context.promptAdmission = undefined; + } + return yield* Effect.interrupt; + } + promptAdmission.accepted = true; + yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe(Effect.ignore); + if ( + context.promptAdmission === promptAdmission && + context.activeTurnId === turnId && + context.promptGeneration === promptAdmission.generation && + promptAdmission.messageObserved + ) { + context.awaitingBusyAfterInterruption = false; + const idle = promptAdmission.idleDuringAdmission; + if (idle && !promptAdmission.idleObservedAfterMessage) { + yield* schedulePromptAdmissionRecovery(context, idle.raw); + } else { + context.promptAdmission = undefined; + } + if (idle && promptAdmission.idleObservedAfterMessage) { + yield* scheduleIdleReconciliation(context, turnId, idle.raw); + } + } else { + yield* schedulePromptAdmissionRecovery(context, promptAdmission.recoveryRaw); + } - return { - threadId: input.threadId, - turnId, - // Re-surface the durable cursor on every turn so the persisted binding - // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; + const stopped = yield* Ref.get(context.stopped); + const finalCancellation = context.cancellation; + if ( + stopped || + sessions.get(input.threadId) !== context || + promptAdmission.cancelled || + context.activeTurnId !== turnId || + context.promptGeneration !== promptAdmission.generation || + finalCancellation?.turnId === turnId + ) { + if (finalCancellation?.turnId === turnId) { + yield* Deferred.await(finalCancellation.completion).pipe(Effect.result); + } + if (context.promptAdmission === promptAdmission) { + context.promptAdmission = undefined; + } + return yield* Effect.interrupt; + } + + return { + threadId: input.threadId, + turnId, + // Re-surface the durable cursor on every turn so the persisted binding + // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }), + ); }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { - yield* emit({ - ...(yield* buildEventBase({ - threadId, - turnId: turnId ?? context.activeTurnId, - })), - type: "turn.aborted", - payload: { - reason: "Interrupted by user.", - }, - }); + const activeTurnId = context.activeTurnId; + if (turnId !== undefined && activeTurnId !== turnId) { + return; + } + const interruptedTurnId = turnId ?? activeTurnId; + yield* cancelIdleReconciliation(context); + if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { + return; + } + const existingCancellation = context.cancellation; + if ( + existingCancellation !== undefined && + existingCancellation.turnId === interruptedTurnId + ) { + return yield* Deferred.await(existingCancellation.completion); + } + const cancellation: OpenCodeCancellation = { + turnId: interruptedTurnId, + completion: Deferred.makeUnsafe(), + }; + context.cancellation = cancellation; + const promptAdmission = context.promptAdmission; + if (promptAdmission !== undefined && promptAdmission.turnId === interruptedTurnId) { + promptAdmission.cancelled = true; + if (promptAdmission.promptFiber) { + yield* Fiber.interrupt(promptAdmission.promptFiber); + } + yield* Deferred.await(promptAdmission.submissionSettled); + } + + const abortOutcome = yield* Effect.raceFirst( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode session abort did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "event" as const, exit })), + ), + ); + if (abortOutcome.source === "event") { + return Exit.isFailure(abortOutcome.exit) + ? yield* Effect.failCause(abortOutcome.exit.cause) + : undefined; + } + const abortExit = abortOutcome.exit; + if (Exit.isFailure(abortExit)) { + if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + return; + } + if (cancellation.turnId === undefined && cancellation.acknowledged) { + if (context.cancellation === cancellation) { + context.cancellation = undefined; + context.reconcileIdleStatus = true; + } + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + return; + } + if (context.cancellation === cancellation) { + context.cancellation = undefined; + if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) { + yield* scheduleIdleReconciliation( + context, + cancellation.turnId, + cancellation.deferredIdleEvent, + ); + } + } + yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore); + return yield* Effect.failCause(abortExit.cause); + } + + if (context.cancellation === cancellation) { + if (cancellation.turnId !== undefined) { + yield* interruptOpenCodeTurn(context, cancellation.turnId); + } else { + context.cancellation = undefined; + context.reconcileIdleStatus = true; + } } + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); }, ); @@ -1628,7 +3047,7 @@ export function makeOpenCodeAdapter( }); } const stopped = yield* stopOpenCodeContext(context); - sessions.delete(threadId); + deleteContextIfCurrent(context); if (!stopped) { return; } diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 7c07fe5ad4..ed84fab997 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -12,8 +12,10 @@ import { ServerConfig } from "../../config.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, + resolveOpenCodeServerPassword, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); @@ -34,8 +36,14 @@ const runtimeMock = { runVersionError: null as Error | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, + connectionError: null as Error | null, inventoryCwd: null as string | null, closeCalls: 0, + sdkClientInputs: [] as Array<{ + baseUrl: string; + directory: string; + serverPassword?: string; + }>, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -46,8 +54,10 @@ const runtimeMock = { this.state.runVersionError = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; + this.state.connectionError = null; this.state.inventoryCwd = null; this.state.closeCalls = 0; + this.state.sdkClientInputs.length = 0; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -57,13 +67,37 @@ const runtimeMock = { }; const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.succeed({ - url: "http://127.0.0.1:4301", - exitCode: Effect.never, + startOpenCodeServerProcess: ({ serverPassword, environment }) => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + const effectiveServerPassword = resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); + return { + url: "http://127.0.0.1:4301", + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { + if (runtimeMock.state.connectionError) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }); + } if (!serverUrl) { yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -73,6 +107,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", exitCode: null, external: Boolean(serverUrl), }; @@ -87,8 +123,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, + createOpenCodeSdkClient: (input) => { + runtimeMock.state.sdkClientInputs.push(input); + return {} as unknown as ReturnType; + }, loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( @@ -132,11 +170,31 @@ const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSe ...overrides, }); +const checkProvider = Effect.fn("checkProvider")(function* ( + settings: OpenCodeSettings, + cwd = process.cwd(), + environment?: NodeJS.ProcessEnv, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: cwd, + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); + }), + ); +}); + it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, false); @@ -150,7 +208,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); @@ -190,7 +248,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); NodeAssert.ok(model); @@ -253,7 +311,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.deepEqual( snapshot.skills.map((skill) => ({ @@ -280,41 +338,109 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("does not spawn a local server for health check (uses CLI instead)", () => + it.effect("loads local inventory from a scoped OpenCode server", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "secret-password", + }, + ]); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.inventoryCwd, null); + }), + ); + + it.effect("uses an environment-only password for local inventory", () => Effect.gen(function* () { - yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + OPENCODE_SERVER_PASSWORD: "environment-password", + }); - NodeAssert.equal(runtimeMock.state.closeCalls, 0); - NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd()); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "environment-password", + }, + ]); + }), + ); + + it.effect("uses the settings password when local environment auth differs", () => + Effect.gen(function* () { + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "settings-password" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ); + + NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); }), ); it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); NodeAssert.equal( snapshot.message, - "Failed to execute OpenCode CLI health check: opencode models failed", + "Failed to load OpenCode provider inventory: opencode models failed", ); }), ); }); it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ); + + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + }, + ]); + }), + ); + + it.effect("rejects an unsupported server before loading inventory", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + it.effect("surfaces a friendly auth error for configured servers", () => Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("401 Unauthorized"); - const snapshot = yield* checkOpenCodeProviderStatus( + runtimeMock.state.connectionError = new Error("401 Unauthorized"); + const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), ); NodeAssert.equal(snapshot.status, "error"); @@ -328,15 +454,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i it.effect("surfaces a friendly connection error for configured servers", () => Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error( + runtimeMock.state.connectionError = new Error( "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", ); - const snapshot = yield* checkOpenCodeProviderStatus( + const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), ); NodeAssert.equal(snapshot.status, "error"); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb..f4ce905642 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -19,17 +19,18 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { + MINIMUM_OPENCODE_VERSION, OpenCodeRuntime, openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause: unknown; @@ -65,6 +66,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined { function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; + readonly phase: "version" | "inventory"; readonly serverUrl: string; }): { readonly installed: boolean; readonly message: string } { const detail = normalizedErrorMessage(input.cause); @@ -127,11 +129,13 @@ function formatOpenCodeProbeError(input: { }; } + const failureLabel = + input.phase === "inventory" + ? "Failed to load OpenCode provider inventory" + : "Failed to execute OpenCode CLI health check"; return { installed: true, - message: detail - ? `Failed to execute OpenCode CLI health check: ${detail}` - : "Failed to execute OpenCode CLI health check.", + message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, }; } @@ -326,17 +330,27 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu openCodeSettings: OpenCodeSettings, cwd: string, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner +> { const openCodeRuntime = yield* OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - const fallback = (cause: unknown, version: string | null = null) => { + const fallback = ( + cause: unknown, + version: string | null = null, + phase: "version" | "inventory" = "version", + ) => { const failure = formatOpenCodeProbeError({ cause, isExternalServer, + phase, serverUrl: openCodeSettings.serverUrl, }); return buildServerProvider({ @@ -417,48 +431,52 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } } - const inventoryExit = yield* Effect.exit( - (isExternalServer - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, - environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }), - ) - : openCodeRuntime.loadInventoryFromCli({ + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, - cwd, - environment: resolvedEnvironment, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), }) - ).pipe( + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( Effect.mapError( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); } + version = inventoryExit.value.version; + const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + flattenOpenCodeModels(inventoryExit.value.inventory), customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); - const connectedCount = inventoryExit.value.providerList.connected.length; + const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 663ee90368..fb336e61b2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -589,18 +589,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, checkedAt: "2026-04-14T00:01:00.000Z", models: [], + slashCommands: [], + skills: [], } satisfies ServerProvider; assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ ...previousProvider.models, ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + [], + ); }); it("drops stale OpenCode models missing from a successful refresh", () => { @@ -670,8 +687,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te capabilities: null, }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, @@ -685,6 +709,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ ...previousProvider.models, ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + previousProvider.slashCommands, + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + previousProvider.skills, + ); }); it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { @@ -713,8 +745,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te capabilities: null, }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const pendingProvider = { ...previousProvider, @@ -732,6 +771,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te auth: { status: "unknown" }, checkedAt: "2026-07-17T00:02:00.000Z", models: [], + slashCommands: [], + skills: [], message: "OpenCode is available, but it did not report any connected upstream providers.", } satisfies ServerProvider; const missingProvider = { @@ -765,6 +806,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te mergeProviderSnapshot(previousProvider, loggedOutProvider).models, [], ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, + [], + ); assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); @@ -896,6 +945,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("refreshes OpenCode catalogs and preserves other providers", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const openCodeDriver = ProviderDriverKind.make("opencode"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const codexRefreshCalls = yield* Ref.make(0); + const openCodeRefreshCalls = yield* Ref.make(0); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const failedOpenCodeProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "error", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + message: "Failed to refresh OpenCode models.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const recoveredOpenCodeProvider = { + ...failedOpenCodeProvider, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:01:00.000Z", + message: "One upstream provider connected through OpenCode.", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const changedCatalogProvider = { + ...recoveredOpenCodeProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + models: [ + { + slug: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + subProvider: "Anthropic", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); + const instances = [ + { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(codexProvider), + refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( + Effect.as(codexProvider), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(failedOpenCodeProvider), + refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(catalogSnapshot)), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + ] satisfies ReadonlyArray; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-reconnect-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const initialProviders = yield* registry.getProviders; + assert.strictEqual( + initialProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.status, + "error", + ); + + const recoveredProviders = yield* registry.refresh(); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.models, + recoveredOpenCodeProvider.models, + ); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), + codexProvider, + ); + + yield* Ref.set(catalogSnapshot, changedCatalogProvider); + const changedProviders = yield* registry.refresh(); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.models, + changedCatalogProvider.models, + ); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === codexInstanceId), + codexProvider, + ); + }).pipe(Effect.provide(runtimeServices)); + + assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); + assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59..310550c08f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => return isPendingInitialProbe || didInstalledProviderProbeFail; }; +const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => + provider.driver === ProviderDriverKind.make("opencode") && + shouldRetainMissingProviderModels(provider); + const mergeProviderModels = ( provider: ServerProvider, previousModels: ReadonlyArray, @@ -132,6 +136,16 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(shouldRetainMissingOpenCodeMetadata(nextProvider) + ? { + slashCommands: + nextProvider.slashCommands.length === 0 + ? previousProvider.slashCommands + : nextProvider.slashCommands, + skills: + nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, + } + : {}), }; export const mergeProviderSnapshots = ( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f88..cc25bf7b22 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -25,6 +24,8 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, describe, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -1145,6 +1146,33 @@ routing.layer("ProviderServiceLive routing", (it) => { const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + const fileAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 456, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "summarize the report", + attachments: [attachment, fileAttachment], + }); + const mixedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(mixedInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.include(mixedInput.input ?? "", `${fileAttachment.id}.pdf]`); + // Every attachment reaches the adapter; each adapter decides what its + // provider ingests natively. + assert.deepEqual(mixedInput.attachments, [attachment, fileAttachment]); + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ threadId: session.threadId, attachments: [fileAttachment] }); + const fileOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -1505,6 +1533,67 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not persist running after a concurrent send is interrupted", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sendStarted = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + routing.codex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(interrupted); + return yield* Effect.interrupt; + }), + ); + routing.codex.interruptTurn.mockImplementationOnce(() => + Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid), + ); + + const threadId = asThreadId("thread-interrupted-send-directory"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const sendExitFiber = yield* provider + .sendTurn({ + threadId: session.threadId, + input: "hold this prompt", + attachments: [], + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(sendStarted); + yield* provider.interruptTurn({ threadId: session.threadId }); + const sendExit = yield* Fiber.join(sendExitFiber); + + assert.equal(Exit.isFailure(sendExit), true); + if (Exit.isFailure(sendExit)) { + assert.equal(Cause.hasInterruptsOnly(sendExit.cause), true); + } + const persisted = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + // The directory folds both adapter "ready" and "running" into its + // runtime "running" state. The payload proves sendTurn did not upsert. + assert.equal(persisted.value.status, "running"); + const payload = persisted.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as { + activeTurnId?: string | null; + lastRuntimeEvent?: string | null; + }; + assert.equal(runtimePayload.activeTurnId ?? null, null); + assert.notEqual(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); + } + } + }), + ); + it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2f91364e39..5c2fa63b4d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -730,13 +730,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - // Adapters inline attachment pixels into the model prompt, but the model's - // tools cannot dereference pixels. Appending the on-disk path is what lets - // a turn like "include this screenshot in the PR" copy the actual file. - // This runs after schema decode, so the appended lines are exempt from the - // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so - // the overhead is bounded. Unresolvable ids are skipped here and surface - // as adapter errors when the file is read for inlining. + // Every attachment gets an on-disk path in the prompt so the model's tools + // can dereference the actual file. All attachments then go to the adapter, + // and each adapter decides what its provider ingests natively: OpenCode + // sends generic files as file parts, the others send images only and rely + // on the path line for everything else. Unresolvable ids are skipped here + // and surface as adapter errors when the file is read. const attachmentPathLines = attachments.flatMap((attachment) => { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -758,13 +757,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(inputTextWithAttachmentPaths !== undefined ? { input: inputTextWithAttachmentPaths } : {}), - attachments, }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, "provider.interaction_mode": input.interactionMode, - "provider.attachment_count": input.attachments.length, + "provider.attachment_count": attachments.length, }); let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; @@ -808,7 +806,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // often, since every toggle restarts the session. Recording it per turn // gives a usage-weighted view and lets it cross with interactionMode. runtimeMode: routed.runtimeMode, - attachmentCount: input.attachments.length, + attachmentCount: attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); return turn; diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts new file mode 100644 index 0000000000..053c0b1bf6 --- /dev/null +++ b/apps/server/src/provider/OpenCodeServerOwner.test.ts @@ -0,0 +1,285 @@ +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import { expect } from "vite-plus/test"; + +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "./opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const unusedRuntimeMethod = () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "unused", + detail: "unused test method", + }), + ); + +const makeRuntime = Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const failNextStart = yield* Ref.make(false); + const started = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + if (yield* Ref.getAndSet(failNextStart, false)) { + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "start failed", + }); + } + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); + yield* Effect.addFinalizer(() => + Ref.update(closes, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.ignore, + ), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + return { runtime, starts, closes, failNextStart, started, closed }; +}); + +it.effect("shares concurrent borrowers and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const release = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const useServer = owner.withServer((server) => + Deferred.await(release).pipe(Effect.as(server.url)), + ); + const fibers = yield* Effect.all([useServer, useServer], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(testRuntime.started); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("retries a failed start and closes on owner scope shutdown", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + yield* Ref.set(testRuntime.failNextStart, true); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect( + (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, + ).toBe("Failure"); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), +); + +it.effect("invalidates an exited process so the next borrower starts a new one", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const processExits: Array> = []; + const processClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const exitCode = yield* Deferred.make(); + processExits.push(exitCode); + yield* Effect.addFinalizer(() => + Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Deferred.await(exitCode), + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Deferred.succeed(processExits[0]!, 1); + yield* Deferred.await(processClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + expect(yield* Ref.get(starts)).toBe(2); + }), +); + +it.effect("replaces a dead cached process before its exit watcher runs", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const processRunning: Array> = []; + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const isRunning = yield* Ref.make(true); + processRunning.push(isRunning); + yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Ref.get(isRunning), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Ref.set(processRunning[0]!, false); + + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + expect(yield* Ref.get(starts)).toBe(2); + expect(yield* Ref.get(closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + }), +); + +it.effect("cleans up an interrupted startup and allows a retry", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const firstStartEntered = yield* Deferred.make(); + const firstStartClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Effect.addFinalizer(() => + index === 1 + ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + if (index === 1) { + yield* Deferred.succeed(firstStartEntered, undefined); + return yield* Effect.never; + } + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const firstBorrower = yield* owner + .withServer((server) => Effect.succeed(server.url)) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStartEntered); + yield* Fiber.interrupt(firstBorrower); + yield* Deferred.await(firstStartClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + }), +); + +it.effect("releases an interrupted borrower and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const borrowerEntered = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const borrower = yield* owner + .withServer(() => + Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(borrowerEntered); + yield* Fiber.interrupt(borrower); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/apps/server/src/provider/OpenCodeServerOwner.ts new file mode 100644 index 0000000000..cccfcaccd6 --- /dev/null +++ b/apps/server/src/provider/OpenCodeServerOwner.ts @@ -0,0 +1,184 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; + +export const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; + +interface OpenCodeServerOwnerState { + server: OpenCodeRuntime.OpenCodeServerProcess | null; + serverScope: Scope.Closeable | null; + borrowers: number; + idleCloseFiber: Fiber.Fiber | null; +} + +export class OpenCodeServerOwner extends Context.Service< + OpenCodeServerOwner, + { + readonly withServer: ( + use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/provider/OpenCodeServerOwner") {} + +/** Owns the lazy local OpenCode server shared by one provider instance. */ +export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const mutex = yield* Semaphore.make(1); + const state: OpenCodeServerOwnerState = { + server: null, + serverScope: null, + borrowers: 0, + idleCloseFiber: null, + }; + + const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { + const fiber = state.idleCloseFiber; + state.idleCloseFiber = null; + if (fiber !== null) { + yield* Fiber.interrupt(fiber).pipe(Effect.ignore); + } + }); + + const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( + expected?: OpenCodeRuntime.OpenCodeServerProcess, + ) { + if (expected !== undefined && state.server !== expected) { + return; + } + const scope = state.serverScope; + state.server = null; + state.serverScope = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( + server: OpenCodeRuntime.OpenCodeServerProcess, + ) { + yield* server.exitCode; + yield* mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + yield* cancelIdleClose(); + yield* closeServer(server); + }), + ); + }); + + const acquireServer = mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + if (state.server !== null) { + if (yield* state.server.isRunning) { + state.borrowers += 1; + return state.server; + } + yield* closeServer(state.server); + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const started = yield* Effect.exit( + restore( + runtime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined + ? { serverPassword: input.serverPassword } + : {}), + ...(input.environment ? { environment: input.environment } : {}), + }) + .pipe(Effect.provideService(Scope.Scope, serverScope)), + ), + ); + if (Exit.isFailure(started)) { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(started.cause); + } + + const server = started.value; + state.server = server; + state.serverScope = serverScope; + state.borrowers = 1; + yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); + return server; + }), + ); + }), + ); + + const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + state.borrowers = Math.max(0, state.borrowers - 1); + if (state.borrowers > 0) { + return; + } + yield* cancelIdleClose(); + state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( + Effect.andThen( + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server || state.borrowers > 0) { + return; + } + state.idleCloseFiber = null; + yield* closeServer(server); + }), + ), + ), + Effect.forkIn(ownerScope), + ); + }), + ); + + yield* Effect.addFinalizer(() => + mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + state.borrowers = 0; + yield* closeServer(); + }), + ), + ); + + return OpenCodeServerOwner.of({ + withServer: (use) => + Effect.uninterruptibleMask((restore) => + restore(acquireServer).pipe( + Effect.flatMap((server) => + restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), + ), + ), + ), + }); +}); + +export const layer = (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; +}) => Layer.effect(OpenCodeServerOwner, make(input)); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 31a4ca6673..52ccfcb518 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -388,7 +388,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("suppresses generic placeholder tool updates until completion", () => + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); @@ -398,13 +398,22 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); - expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); - const toolCall = notes[0]; - expect(toolCall?._tag).toBe("ToolCallUpdated"); - if (toolCall?._tag === "ToolCallUpdated") { - expect(toolCall.toolCall.status).toBe("completed"); - expect(toolCall.toolCall.title).toBe("Read file"); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3))); + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "ToolCallUpdated", + "ToolCallUpdated", + ]); + const toolCalls = notes.flatMap((note) => + note._tag === "ToolCallUpdated" ? [note.toolCall] : [], + ); + expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([ + "pending", + "inProgress", + "completed", + ]); + for (const toolCall of toolCalls) { + expect(toolCall.title).toBe("Read file"); } }).pipe( Effect.provide( diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 568df19571..f322aeff9c 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -11,6 +11,7 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + toolCallProgressLength, type AcpToolCallState, } from "./AcpRuntimeModel.ts"; @@ -572,6 +573,130 @@ describe("AcpRuntimeModel", () => { }); }); + it("keeps a retained tail on the original text entries around non-text content", () => { + const prefix = "a".repeat(4_000); + const suffix = "b".repeat(5_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: prefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: suffix } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + const firstText = content[0]; + if (firstText?.type !== "content" || firstText.content.type !== "text") { + throw new Error("expected a bounded prefix text entry"); + } + expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); + expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); + expect(content[1]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + expect(content[2]).toEqual({ + type: "content", + content: { type: "text", text: suffix }, + }); + }); + + it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { + // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) + // and used to be returned unchanged, which let a redrawing terminal persist unbounded + // buffers on `toolCall.data.content` and `rawPayload`. + const hugeWhitespace = " \n\t".repeat(30_000); + expect(hugeWhitespace.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeUndefined(); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text.length).toBeLessThan(8_100); + expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); + }); + + it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { + const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; + expect(padded.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: padded } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBe("ok"); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text).toBe("ok"); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text).toBe("ok"); + expect(JSON.stringify(event).length).toBeLessThan(padded.length); + }); + describe("decideToolCallUpdateEmission", () => { const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => ({ @@ -582,6 +707,17 @@ describe("AcpRuntimeModel", () => { data: {}, }) satisfies AcpToolCallState; + it("emits the first in-progress tool_call even when it has no detail", () => { + expect( + decideToolCallUpdateEmission({ + previous: undefined, + next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, + lastEmittedDetailLength: undefined, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + it("always emits terminal (completed/failed) status updates regardless of growth", () => { expect( decideToolCallUpdateEmission({ @@ -603,16 +739,66 @@ describe("AcpRuntimeModel", () => { }); it("skips updates whose bounded detail did not change", () => { + const previous = toolCall("frame 1", "inProgress"); expect( decideToolCallUpdateEmission({ - previous: toolCall("frame 1", "inProgress"), - next: toolCall("frame 1", "inProgress"), + previous, + next: previous, lastEmittedDetailLength: 7, skippedSinceEmit: 0, }), ).toEqual({ emit: false, skippedSinceEmit: 0 }); }); + it("coalesces command-tool updates whose content grew while detail stayed the command", () => { + const commandCall = (stdout: string): AcpToolCallState => ({ + toolCallId: "tool-1", + title: "Ran command", + status: "inProgress", + command: "ls", + detail: "ls", + data: { + command: "ls", + content: [{ type: "content", content: { type: "text", text: stdout } }], + }, + }); + + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + const emissions: Array = []; + + for (let i = 1; i <= 12; i += 1) { + const next = commandCall("x".repeat(i)); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = toolCallProgressLength(next); + } + previous = next; + } + + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("emits pending to inProgress status changes even when detail and output are unchanged", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "pending"), + next: toolCall("same", "inProgress"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + it("emits immediately when the title changes, even with no growth", () => { const decision = decideToolCallUpdateEmission({ previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index a6535f1f31..3b316764ee 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -328,6 +328,32 @@ function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | u return entry.content.text; } +// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never +// contribute to `chunks` and used to take the early returns with the original array. Bound +// each text entry independently so those paths cannot persist an unbounded terminal buffer +// on `toolCall.data.content` / `rawPayload`. +function boundToolCallContentEntries( + content: ReadonlyArray, +): ReadonlyArray { + let changed = false; + const bounded = content.map((entry) => { + const text = toolCallContentText(entry); + if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return entry; + } + changed = true; + const trimmed = text.trim(); + return { + type: "content", + content: { + type: "text", + text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), + }, + } as const; + }); + return changed ? bounded : content; +} + function extractTextContentFromToolCallContent( content: ReadonlyArray | null | undefined, ): ExtractedToolCallContent { @@ -342,31 +368,74 @@ function extractTextContentFromToolCallContent( } } if (chunks.length === 0) { - return { text: undefined, content }; + return { text: undefined, content: boundToolCallContentEntries(content) }; } const joined = chunks.join("\n"); if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { - return { text: joined, content }; + return { text: joined, content: boundToolCallContentEntries(content) }; } const bounded = boundToolCallOutputText(joined); - // Collapse the text entries into a single bounded one at the final contributing text entry, - // and leave every other entry kind (diffs, images, resource links) in its original relative - // order. The retained tail came from that text entry, so placing it there also preserves its - // ordering relative to interleaved non-text content and ignores later blank text entries. - const lastContributingTextIndex = content.reduce( - (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex), - -1, - ); - const boundedContent = content.flatMap((entry, index) => { + const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); + return { + text: bounded, + content: distributeRetainedTailAcrossContent(content, tail), + }; +} + +// Walk the original text entries from the joined tail window so a retained slice that +// spans entries around an image/diff stays on those entries. Non-text kinds keep their +// relative order; blank text entries are dropped; the truncation marker is prepended to +// the first remaining text entry. +function distributeRetainedTailAcrossContent( + content: ReadonlyArray, + tail: string, +): ReadonlyArray { + const textRanges: Array< + | { + readonly start: number; + readonly end: number; + readonly text: string; + } + | undefined + > = Array.from({ length: content.length }); + let offset = 0; + let seenText = false; + for (const [index, entry] of content.entries()) { + const text = toolCallContentText(entry)?.trim(); + if (!text) { + continue; + } + if (seenText) { + offset += 1; + } + seenText = true; + const start = offset; + const end = offset + text.length; + textRanges[index] = { start, end, text }; + offset = end; + } + const tailStart = Math.max(0, offset - tail.length); + let markerPending = true; + return content.flatMap((entry, index) => { if (toolCallContentText(entry) === undefined) { return [entry]; } - if (index !== lastContributingTextIndex) { + const range = textRanges[index]; + if (range === undefined) { + return []; + } + const overlapStart = Math.max(range.start, tailStart); + const overlapEnd = Math.min(range.end, offset); + if (overlapEnd <= overlapStart) { return []; } - return [{ type: "content", content: { type: "text", text: bounded } } as const]; + let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); + if (markerPending) { + piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; + markerPending = false; + } + return [{ type: "content", content: { type: "text", text: piece } } as const]; }); - return { text: bounded, content: boundedContent }; } function normalizeToolKind(kind: unknown): string | undefined { @@ -530,6 +599,42 @@ export interface AcpToolCallEmitDecision { readonly skippedSinceEmit: number; } +function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { + return ( + previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput + ); +} + +// Command tools keep `detail` equal to the command, so live stdout lives on +// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never +// sees growth and in-progress output is held until completed/failed. +export function toolCallProgressLength(state: AcpToolCallState): number { + let contentChars = 0; + const content = state.data.content; + if (Array.isArray(content)) { + for (const entry of content) { + if (!isRecord(entry)) { + continue; + } + const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); + if (text) { + contentChars += text.length; + } + } + } + let rawOutputChars = 0; + const rawOutput = state.data.rawOutput; + if (isRecord(rawOutput)) { + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string") { + rawOutputChars += value.length; + } + } + } + return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); +} + export function decideToolCallUpdateEmission( input: AcpToolCallEmitDecisionInput, ): AcpToolCallEmitDecision { @@ -537,19 +642,16 @@ export function decideToolCallUpdateEmission( if (next.status === "completed" || next.status === "failed") { return { emit: true, skippedSinceEmit: 0 }; } - if (!next.detail) { - return { emit: false, skippedSinceEmit }; - } - if (previous === undefined || previous.title !== next.title) { + if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { return { emit: true, skippedSinceEmit: 0 }; } - if (previous.detail === next.detail) { + if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { return { emit: false, skippedSinceEmit }; } + const progressLength = toolCallProgressLength(next); const grewMeaningfully = lastEmittedDetailLength === undefined || - Math.abs(next.detail.length - lastEmittedDetailLength) >= - TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; + Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { return { emit: true, skippedSinceEmit: 0 }; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 522ac596b3..5eb719658d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -28,6 +28,7 @@ import { extractModelConfigId, findSessionConfigOption, mergeToolCallState, + toolCallProgressLength, parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, @@ -235,6 +236,7 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setSessionModel: ( modelId: string, + meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -816,12 +818,13 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, meta) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(meta !== undefined ? { _meta: meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", @@ -913,7 +916,7 @@ const handleSessionUpdate = ({ next.set(nextToolCall.toolCallId, { state: nextToolCall, lastEmittedDetailLength: decision.emit - ? nextToolCall.detail?.length + ? toolCallProgressLength(nextToolCall) : tracked?.lastEmittedDetailLength, skippedSinceEmit: decision.skippedSinceEmit, }); diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 222fc4a12d..ad6eaac1b5 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -1,6 +1,7 @@ /** * Optional integration check against a real `grok agent stdio` install. - * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe + * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe + * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model. * * The probe assumes either `XAI_API_KEY` is set in the environment or * the user has previously run `grok login`. Without credentials the @@ -10,6 +11,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect } from "vite-plus/test"; @@ -66,4 +71,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = yield* runtime.setSessionModel(currentModelId); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/set_model accepts advertised reasoning effort metadata", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const modelState = started.sessionSetupResult.models; + const currentModelId = modelState?.currentModelId.trim(); + expect(currentModelId).toBeDefined(); + if (!currentModelId) return; + + const currentModel = modelState?.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + expect(typeof reasoningEffort).toBe("string"); + if (typeof reasoningEffort !== "string") return; + + yield* runtime.setSessionModel(currentModelId, { reasoningEffort }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")( + "finishes a real Grok turn and streams its answer", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeGrokAcpRuntime({ + grokSettings: { binaryPath: "grok" }, + environment: process.env, + childProcessSpawner, + cwd, + runtimeMode: "approval-required", + clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, + }); + yield* runtime.start(); + const chunks: string[] = []; + const events = yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ContentDelta") { + chunks.push(event.text); + } + return Effect.void; + }).pipe(Effect.forkChild); + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }], + }); + yield* runtime.drainEvents; + expect(result.stopReason).toBe("end_turn"); + expect(chunks.join("")).toContain("GROK_T3_OK"); + yield* Fiber.interrupt(events); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b2..a85f7174a1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, + grokAcpSpawnArgs, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => { }); }); +describe("grokAcpSpawnArgs", () => { + it("inherits the Grok CLI config when no T3 runtime mode is set", () => { + expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]); + }); + + it("forces Grok to ask when T3 is Supervised", () => { + expect(grokAcpSpawnArgs("approval-required")).toEqual([ + "--permission-mode", + "default", + "agent", + "stdio", + ]); + }); + + it("maps Full access to Grok always-approve", () => { + expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]); + }); + + it("maps Auto-accept edits and Auto onto Grok permission modes", () => { + expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([ + "--permission-mode", + "acceptEdits", + "agent", + "stdio", + ]); + expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); + }); +}); + describe("buildGrokAcpSpawnInput", () => { it("passes the T3 Code referrer through Grok OAuth env", () => { const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { @@ -33,15 +64,38 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("puts Supervised on the Grok argv so config always-approve cannot win", () => { + const spawn = buildGrokAcpSpawnInput( + { binaryPath: "/usr/local/bin/grok" }, + "/tmp/project", + undefined, + "approval-required", + ); + expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]); + }); +}); + +describe("isValidGrokReasoningEffortToken", () => { + it("accepts future ACP tokens and rejects malformed metadata values", () => { + expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true); + expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true); + expect(isValidGrokReasoningEffortToken("not a token")).toBe(false); + expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false); + expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false); + }); }); describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ + modelId: string; + meta?: { readonly [key: string]: unknown } | null; + }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); if (failure) return yield* failure; return {}; }), @@ -58,11 +112,58 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); expect(result).toBe("grok-mock-alt"); }), ); + it.effect("applies reasoning effort through session/set_model metadata", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("does not clear reasoning when same-model selection omits effort", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("drops malformed effort metadata instead of sending it", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "not a token", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); + }), + ); + it.effect("skips set_model when requested matches current", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80..001f0adfe7 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,4 @@ -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,16 +27,33 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "approval-required": + return ["--permission-mode", "default", "agent", "stdio"]; + case "auto-accept-edits": + return ["--permission-mode", "acceptEdits", "agent", "stdio"]; + case "auto": + return ["--permission-mode", "auto", "agent", "stdio"]; + case "full-access": + return ["agent", "--always-approve", "stdio"]; + default: + return ["agent", "stdio"]; + } } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, ): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: [...grokAcpSpawnArgs(runtimeMode)], cwd, env: { ...environment, @@ -62,7 +79,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), authMethodId: resolveGrokAuthMethodId(input.environment), }).pipe( Layer.provide( @@ -82,6 +104,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; } +const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; + +export function isValidGrokReasoningEffortToken(value: string): boolean { + return GROK_REASONING_EFFORT_TOKEN.test(value); +} + +export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { + const effort = value?.trim(); + return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; +} + export function currentGrokModelIdFromSessionSetup( sessionSetupResult: | EffectAcpSchema.LoadSessionResponse @@ -91,18 +124,57 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function currentGrokReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelState = sessionSetupResult.models; + if (!modelState) { + return undefined; + } + const currentModelId = modelState.currentModelId.trim(); + if (currentModelId.length === 0) { + return undefined; + } + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + return typeof reasoningEffort === "string" + ? normalizeGrokReasoningEffort(reasoningEffort) + : undefined; +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; + readonly currentReasoningEffort?: string | undefined; readonly requestedModelId: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const shouldSwitchModel = + const modelChanged = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { + const reasoningProvided = input.requestedReasoningEffort !== undefined; + const reasoningEffort = reasoningProvided + ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) + : undefined; + const reasoningEffortChanged = + reasoningProvided && reasoningEffort !== input.currentReasoningEffort; + const targetModelId = input.requestedModelId ?? input.currentModelId; + if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } + const reasoningMeta = + reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; + // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately + // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not + // provided at all, we also send no meta, but we only reach this call when the model itself + // changed - an omitted reasoning preference must not be treated as an explicit clear of the + // CLI-advertised default (e.g. Extra High) on same-model reselections. return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel(targetModelId, reasoningMeta) + .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd7..28f5f29f49 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -9,11 +10,17 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, + isGrokPlanMarkdownPath, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, + XAI_EMPTY_PLAN_MARKDOWN, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("fails a hung standard prompt from an xAI rate-limit completion", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }); + yield* runtime.start(); + + const error = yield* Effect.flip( + runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }), + ); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32003, + errorMessage: "Grok usage limit reached. Try again later.", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("ignores stale xAI completion from an already settled prompt", () => Effect.gen(function* () { const runtime = yield* makePromptCompletionRuntime({ @@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => { }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it("extracts plan markdown from exit_plan_mode payloads", () => { + const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest); + const direct = decode({ + sessionId: "session-1", + toolCallId: "exit-1", + planContent: "# Plan\n\n- do the thing\n", + }); + expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing"); + + const wrapped = decode({ + method: "_x.ai/exit_plan_mode", + params: { + sessionId: "session-1", + toolCallId: "exit-1", + planContent: null, + }, + }); + expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan"); + expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN); + expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); + }); + + it("builds an abandoned exit_plan_mode response that captures the plan", () => { + expect(makeXAiExitPlanModeCapturedResponse()).toEqual({ + outcome: "abandoned", + feedback: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }); + }); + + it("identifies Grok plan.md paths and extracts markdown from tool call data", () => { + const linuxHost = { platform: "linux" as const, environment: {} }; + const windowsHost = { platform: "win32" as const, environment: {} }; + const grokHomeHost = { + platform: "linux" as const, + environment: { GROK_HOME: "/opt/grok-data" }, + }; + const home = NodeOS.homedir().replace(/\\/g, "/"); + const sessionPlan = `${home}/.grok/sessions/abc/plan.md`; + const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`; + expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe( + false, + ); + expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe( + true, + ); + expect( + isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true); + expect( + isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", { + platform: "win32", + environment: { GROK_HOME: "/opt/grok-data" }, + }), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( + false, + ); + // Workspace plan.md must not be treated as the session plan file. + expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false); + expect( + isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost), + ).toBe(false); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { + file_path: sessionPlan, + content: "# From rawInput\n\n- a\n", + }, + }, + linuxHost, + ), + ).toBe("# From rawInput\n\n- a"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff\n\n- b\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff\n\n- b"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff after empty rawInput\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff after empty rawInput"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }], + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/tmp/readme.md", content: "nope" }, + }, + linuxHost, + ), + ).toBeUndefined(); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" }, + }, + linuxHost, + ), + ).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc8..543edb39bb 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,8 +1,11 @@ +import * as NodeOS from "node:os"; + import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; interface PendingXAiPromptCompletion { readonly sessionId: string; readonly promptId: string; - readonly deferred: Deferred.Deferred; + readonly deferred: Deferred.Deferred< + EffectAcpSchema.PromptResponse, + EffectAcpErrors.AcpRequestError + >; } const completedXAiPromptIdLimit = 128; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; +const xAiRateLimitedErrorCode = -32003; const XAiAskUserQuestionOption = Schema.Struct({ label: Schema.String, @@ -196,6 +203,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +// --------------------------------------------------------------------------- +// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) +// --------------------------------------------------------------------------- + +const XAiExitPlanModeParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + planContent: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const XAiWrappedExitPlanModeParams = Schema.Struct({ + method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), + params: XAiExitPlanModeParams, +}); + +export const XAiExitPlanModeRequest = Schema.Union([ + XAiExitPlanModeParams, + XAiWrappedExitPlanModeParams, +]); + +type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; +type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; + +function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { + return "params" in params ? params.params : params; +} + +/** Empty-state copy when Grok exits plan mode without a plan file. */ +export const XAI_EMPTY_PLAN_MARKDOWN = + "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; + +export function extractXAiExitPlanMarkdown( + params: XAiExitPlanModeRequest, + fallback?: string | null, +): string { + const content = unwrapExitPlanModeParams(params).planContent; + const fromRequest = typeof content === "string" ? trimmed(content) : undefined; + if (fromRequest) { + return fromRequest; + } + const fromFallback = fallback?.trim(); + if (fromFallback && fromFallback.length > 0) { + return fromFallback; + } + return XAI_EMPTY_PLAN_MARKDOWN; +} + +export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; + +export interface XAiExitPlanModeResponse { + readonly outcome: XAiExitPlanModeOutcome; + readonly feedback?: string; +} + +/** + * Client captured the plan for T3's proposed-plan card. Abandon the native + * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. + */ +export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { + return { + outcome: "abandoned", + feedback: + feedback ?? + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }; +} + +function normalizeFsPath(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function pathHasTraversalSegment(normalized: string): boolean { + return normalized.split("/").includes(".."); +} + +function addGrokSessionPrefix( + prefixes: Set, + homeOrRoot: string, + nestedGrokDir: boolean, +): void { + const root = normalizeFsPath(homeOrRoot); + if (!root) { + return; + } + prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); +} + +/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ +export interface GrokPlanPathHost { + readonly platform: NodeJS.Platform; + readonly environment: NodeJS.ProcessEnv; +} + +function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet { + const prefixes = new Set(); + addGrokSessionPrefix(prefixes, NodeOS.homedir(), true); + addGrokSessionPrefix(prefixes, "~", true); + addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); + addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); + // ACP mock and isolated Grok spawns use a HOME that is not the server process home. + addGrokSessionPrefix(prefixes, "/tmp/mock-home", true); + const grokHome = environment.GROK_HOME ?? ""; + addGrokSessionPrefix(prefixes, grokHome, false); + addGrokSessionPrefix(prefixes, grokHome, true); + return prefixes; +} + +const CANONICAL_HOME_GROK_SESSION_PATH = + /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; +const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( + CANONICAL_HOME_GROK_SESSION_PATH.source, + "i", +); + +/** + * True when a path is Grok's session plan file under a Grok home + * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). + * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md + * or a repo-local `.grok/sessions/.../plan.md`). + */ +export function isGrokPlanMarkdownPath( + path: string | undefined | null, + host: GrokPlanPathHost, +): boolean { + if (typeof path !== "string") { + return false; + } + const normalized = path.trim().replace(/\\/g, "/"); + const win32 = host.platform === "win32"; + const haystack = win32 ? normalized.toLowerCase() : normalized; + if ( + normalized.length === 0 || + !haystack.endsWith("/plan.md") || + pathHasTraversalSegment(normalized) + ) { + return false; + } + for (const prefix of grokPlanSessionPrefixes(host.environment)) { + const needle = win32 ? prefix.toLowerCase() : prefix; + if (!haystack.startsWith(needle)) { + continue; + } + const rest = haystack.slice(needle.length); + // Session layout: /.grok/sessions///plan.md + if (rest !== "plan.md" && rest.endsWith("plan.md")) { + return true; + } + } + return ( + win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH + ).test(haystack); +} + +/** + * Extract plan markdown from a Grok write/edit tool call targeting plan.md. + * Used so T3 can show the plan while plan mode is still active (before exit). + */ +export function extractGrokPlanMarkdownFromToolCallData( + data: Record | undefined, + host: GrokPlanPathHost, +): string | undefined { + if (!data) { + return undefined; + } + + let sawPlanWrite = false; + const takePlanText = ( + value: string | undefined, + filePath: string | undefined, + ): string | undefined => { + if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { + return undefined; + } + sawPlanWrite = true; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + const rawInput = data.rawInput; + if (isRecord(rawInput)) { + const filePath = + (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? + (typeof rawInput.path === "string" ? rawInput.path : undefined); + const content = typeof rawInput.content === "string" ? rawInput.content : undefined; + const fromRaw = takePlanText(content, filePath); + if (fromRaw !== undefined) { + return fromRaw; + } + } + + const content = data.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!isRecord(block) || block.type !== "diff") { + continue; + } + const path = typeof block.path === "string" ? block.path : undefined; + const newText = typeof block.newText === "string" ? block.newText : undefined; + const fromDiff = takePlanText(newText, path); + if (fromDiff !== undefined) { + return fromDiff; + } + } + } + + return sawPlanWrite ? "" : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata. @@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = ( sessionId: string, promptId: string, ) => - Deferred.make().pipe( + Deferred.make().pipe( Effect.tap((deferred) => Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), ), @@ -287,7 +506,7 @@ const registerXAiPromptCompletionFallback = ( const unregisterXAiPromptCompletionFallback = ( pendingRef: Ref.Ref>, - deferred: Deferred.Deferred, + deferred: Deferred.Deferred, ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); const abortPendingPromptCompletions = ( @@ -358,13 +577,48 @@ const resolveXAiPromptCompletionFallback = ({ return [Effect.void, pending] as const; } return [ - Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), + settleXAiPromptCompletion(entry.deferred, notification), [...pending.slice(0, index), ...pending.slice(index + 1)], ] as const; }).pipe(Effect.flatten); }), ); +const settleXAiPromptCompletion = ( + deferred: Deferred.Deferred, + notification: XAiPromptCompleteNotification, +) => { + if (notification.stopReason === "rate_limit") { + return Deferred.fail( + deferred, + new EffectAcpErrors.AcpRequestError({ + code: xAiRateLimitedErrorCode, + errorMessage: "Grok usage limit reached. Try again later.", + }), + ).pipe(Effect.asVoid); + } + if (notification.stopReason === "error") { + return Deferred.fail( + deferred, + EffectAcpErrors.AcpRequestError.internalError( + xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.", + ), + ).pipe(Effect.asVoid); + } + return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid); +}; + +function xAiAgentResultMessage(value: unknown): string | undefined { + if (typeof value === "string") { + return trimmed(value); + } + if (value === null || typeof value !== "object") { + return undefined; + } + const message = "message" in value ? value.message : undefined; + return typeof message === "string" ? trimmed(message) : undefined; +} + const rememberCompletedXAiPromptId = ( completedPromptIdsRef: Ref.Ref>, response: EffectAcpSchema.PromptResponse, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cf..fd50fa13eb 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), ); + it.effect("keeps manual refresh when interval refresh is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + refreshOnInterval: false, + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("5 minutes"); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 1); + + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), + ); + it.effect("wakes a sleeping provider refresh loop when its interval changes", () => Effect.scoped( Effect.gen(function* () { @@ -355,6 +389,41 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("can update settings and disable periodic checks without probing again", () => + Effect.scoped( + Effect.gen(function* () { + const settingsChanges = yield* PubSub.unbounded(); + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const enrichmentCalls = yield* Ref.make(0); + yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.fromPubSub(settingsChanges), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), + Effect.as(refreshedSnapshot), + ), + enrichSnapshot: () => Ref.update(enrichmentCalls, (count) => count + 1), + refreshInterval: "1 second", + }); + + yield* Deferred.await(initialCheckDone); + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Effect.yieldNow; + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.strictEqual(yield* Ref.get(enrichmentCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), + ); + it.effect("streams supplemental snapshot updates after the base provider check completes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index d2b6b52e8f..a009157144 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -40,6 +40,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; }) => Effect.Effect; readonly refreshInterval?: Duration.Input; + readonly refreshOnInterval?: boolean; + readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; }): Effect.fn.Return< ServerProviderShape, ServerSettingsError, @@ -121,6 +123,21 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); } + if ( + !forceRefresh && + input.checkProviderOnSettingsChange?.(previousSettings, nextSettings) === false + ) { + const state = yield* Ref.get(snapshotStateRef); + const nextGeneration = state.enrichmentGeneration + 1; + yield* Ref.set(snapshotStateRef, { + ...state, + enrichmentGeneration: nextGeneration, + }); + yield* Ref.set(settingsRef, nextSettings); + yield* restartSnapshotEnrichment(nextSettings, state.snapshot, nextGeneration); + return state.snapshot; + } + const nextSnapshot = yield* input.checkProvider; const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { const generation = input.enrichSnapshot @@ -199,7 +216,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), ).pipe( Effect.flatMap((intervalElapsed) => - intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 + input.refreshOnInterval !== false && + intervalElapsed && + Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 ? hasProviderStatusDemand.pipe( Effect.flatMap((shouldRefresh) => shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index f02bf997c5..35a7791c62 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -6,6 +6,7 @@ import { parseAgentListCliOutput, parseModelsCliOutput, parseSkillsCliOutput, + toOpenCodeFileParts, } from "./opencodeRuntime.ts"; describe("parseModelsCliOutput", () => { @@ -283,3 +284,38 @@ describe("parseSkillsCliOutput", () => { NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); }); }); + +describe("toOpenCodeFileParts", () => { + const attachment = (mimeType: string, sizeBytes = 12) => ({ + type: "file" as const, + id: "thread-1-00000000-0000-4000-8000-000000000001-bin", + name: "attachment", + mimeType, + sizeBytes, + }); + + it("sends supported images, text, and PDFs natively and skips what models reject", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + attachment("application/pdf"), + attachment("text/markdown"), + attachment("image/png"), + // A ZIP file part makes OpenCode's Anthropic path throw before the + // turn starts; it must ride only as the prompt's file path line. + attachment("application/zip"), + attachment("application/octet-stream"), + // Image formats the model APIs reject stay on the fallback path too. + attachment("image/bmp"), + attachment("image/svg+xml"), + // Over the direct-attachment limit: path fallback even for a PDF. + attachment("application/pdf", 21 * 1024 * 1024), + ], + resolveAttachmentPath: () => "/tmp/attachment", + }); + + NodeAssert.deepEqual( + parts.map((part) => part.mime), + ["application/pdf", "text/markdown", "image/png"], + ); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts index b56921a686..584a9d80fb 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/apps/server/src/provider/opencodeRuntime.environment.test.ts @@ -1,6 +1,16 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it } from "vite-plus/test"; -import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; +import { + OpenCodeRuntimeError, + resolveOpenCodeConfigContent, + resolveOpenCodeServerPassword, + verifyOpenCodeServerVersion, +} from "./opencodeRuntime.ts"; describe("resolveOpenCodeConfigContent", () => { it("prefers the caller environment over the inherited environment", () => { @@ -21,3 +31,122 @@ describe("resolveOpenCodeConfigContent", () => { expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); }); }); + +describe("resolveOpenCodeServerPassword", () => { + it("uses the local environment password when settings do not provide one", () => { + expect( + resolveOpenCodeServerPassword( + { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, + {}, + ), + ).toBe(" env password "); + }); + + it("uses the settings password for a local server", () => { + expect( + resolveOpenCodeServerPassword({ external: false, serverPassword: " settings password " }, {}), + ).toBe(" settings password "); + }); + + it("uses the settings password when local settings and environment differ", () => { + expect( + resolveOpenCodeServerPassword( + { + external: false, + serverPassword: "settings-password", + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }, + {}, + ), + ).toBe("settings-password"); + }); + + it("does not send an inherited local password to an external server", () => { + expect( + resolveOpenCodeServerPassword( + { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, + { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, + ), + ).toBeUndefined(); + }); +}); + +function makeHealthClient( + result: (options?: { readonly signal?: AbortSignal }) => Promise, +): OpencodeClient { + return { + global: { + health: result, + }, + } as unknown as OpencodeClient; +} + +describe("verifyOpenCodeServerVersion", () => { + effectIt.effect("accepts a supported server version", () => + Effect.gen(function* () { + const version = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), + ); + expect(version).toBe("1.14.19"); + }), + ); + + effectIt.effect("rejects a server below the supported version", () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("v1.14.18 is too old"); + }), + ); + + for (const data of [ + { healthy: true }, + { healthy: true, version: "not-a-version" }, + { healthy: false, version: "1.14.19" }, + ]) { + effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); + }), + ); + } + + effectIt.effect("preserves an unauthorized health error", () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => + Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), + ), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("status=401"); + expect(error.detail).toContain("Unauthorized"); + }), + ); + + effectIt.effect("aborts a health request when the version check times out", () => + Effect.gen(function* () { + let requestSignal: AbortSignal | undefined; + const checkFiber = yield* verifyOpenCodeServerVersion( + makeHealthClient((options) => { + requestSignal = options?.signal; + return new Promise(() => undefined); + }), + ).pipe(Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + expect(requestSignal).toBeDefined(); + yield* TestClock.adjust("6 seconds"); + + const error = yield* Fiber.join(checkFiber); + expect(error.detail).toBe("Timed out while checking the OpenCode server version."); + expect(requestSignal?.aborted).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 7db63745ea..2a878a24ab 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { + it.effect("keeps provider inventory when agent discovery fails", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.reject(new Error("agents endpoint unavailable")), + skills: () => Promise.resolve({ data: [] }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.deepEqual(inventory.agents, []); + NodeAssert.deepEqual(inventory.skills, []); + }), + ); + it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts index ad95e38d14..be2696d7e1 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => { it("allows everything only under full access", () => { NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, ]); }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 80329a6794..139628a287 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -33,10 +33,20 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; +export const MINIMUM_OPENCODE_VERSION = "1.14.19"; +const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; + +const OpenCodeHealthSchema = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}); +const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); + export function resolveOpenCodeConfigContent( inputEnvironment: Readonly> | undefined, inheritedEnvironment: Readonly> = process.env, @@ -48,17 +58,41 @@ export function resolveOpenCodeConfigContent( ); } +export function resolveOpenCodeServerPassword( + input: { + readonly external: boolean; + readonly serverPassword?: string; + readonly environment?: Readonly>; + }, + inheritedEnvironment: Readonly> = process.env, +): string | undefined { + if (input.serverPassword !== undefined) { + return input.serverPassword; + } + if (input.external) { + return undefined; + } + return input.environment === undefined + ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD + : input.environment.OPENCODE_SERVER_PASSWORD; +} + const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; + readonly serverPassword?: string; + readonly version: string; + readonly isRunning: Effect.Effect; readonly exitCode: Effect.Effect; } export interface OpenCodeServerConnection { readonly url: string; + readonly serverPassword?: string; + readonly version: string; readonly exitCode: Effect.Effect | null; readonly external: boolean; } @@ -96,7 +130,7 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { export const runOpenCodeSdk = ( operation: string, - fn: () => Promise, + fn: (signal: AbortSignal) => Promise, ): Effect.Effect => Effect.tryPromise({ try: fn, @@ -104,6 +138,44 @@ export const runOpenCodeSdk = ( new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), }).pipe(Effect.withSpan(`opencode.${operation}`)); +export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( + client: OpencodeClient, +) { + const healthOption = yield* runOpenCodeSdk("global.health", (signal) => + client.global.health({ signal }), + ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); + if (Option.isNone(healthOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: "Timed out while checking the OpenCode server version.", + }); + } + + const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + cause, + }), + ), + ); + if (parseSemver(health.version) === null) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + return health.version; +}); + export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; @@ -145,6 +217,8 @@ export interface OpenCodeRuntimeShape { */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; @@ -157,7 +231,9 @@ export interface OpenCodeRuntimeShape { */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; + readonly directory: string; readonly serverUrl?: string | null; + readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; @@ -345,6 +421,31 @@ export function openCodeQuestionId( return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; } +/** + * Attachments OpenCode can hand to a model as a native file part. Anything + * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs + * reject, or files over the direct-attachment size limit) would make the turn + * fail before it starts, so those ride only as the file path ProviderService + * puts in the prompt. + */ +const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; + +export function isOpenCodeNativeFilePart(input: { + readonly mimeType: string; + readonly sizeBytes: number; +}): boolean { + if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { + return false; + } + const normalized = input.mimeType.trim().toLowerCase(); + return ( + OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || + normalized.startsWith("text/") || + normalized === "application/pdf" + ); +} + export function toOpenCodeFileParts(input: { readonly attachments: ReadonlyArray | undefined; readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; @@ -352,6 +453,9 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if (!isOpenCodeNativeFilePart(attachment)) { + continue; + } const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { continue; @@ -370,7 +474,10 @@ export function toOpenCodeFileParts(input: { export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { if (runtimeMode === "full-access") { - return [{ permission: "*", pattern: "*", action: "allow" }]; + return [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; } // "Auto-accept edits" is documented as "auto-approve edits, ask before other @@ -486,6 +593,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); + const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }); + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => Effect.gen(function* () { // Bind this server's lifetime to the caller's scope. When the caller's @@ -509,6 +630,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); + const serverPassword = resolveOpenCodeServerPassword({ + external: false, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); const child = yield* spawner .spawn( @@ -517,6 +643,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { shell: spawnCommand.shell, env: { ...input.environment, + ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or // the inherited process environment, only falling back to the // empty config when neither is set. Setting it unconditionally @@ -642,8 +769,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); } + const url = readyOption.value; + const version = yield* verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ); + return { - url: readyOption.value, + url, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), exitCode: child.exitCode.pipe( Effect.map(Number), Effect.orElseSucceed(() => 0), @@ -654,16 +793,31 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { - // We don't own externally-configured servers — no scope interaction. - return Effect.succeed({ - url: serverUrl, - exitCode: null, + const serverPassword = resolveOpenCodeServerPassword({ external: true, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), }); + return verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: serverUrl, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ).pipe( + Effect.map((version) => ({ + url: serverUrl, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + exitCode: null, + external: true, + })), + ); } return startOpenCodeServerProcess({ binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), ...(input.environment !== undefined ? { environment: input.environment } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), @@ -671,26 +825,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }).pipe( Effect.map((server) => ({ url: server.url, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + version: server.version, exitCode: server.exitCode, external: false, })), ); }; - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, - }); - const loadProviders = (client: OpencodeClient) => runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( Effect.filterMapOrFail( @@ -710,6 +852,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadAgents = (client: OpencodeClient) => runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( Effect.map((result) => result.data ?? []), + Effect.orElseSucceed((): ReadonlyArray => []), ); const loadSkills = (client: OpencodeClient) => diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index d3bdee3671..4e6d1b2619 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,7 +57,55 @@ rl.on("line", (line) => { }); return; } - if (method === "thread/start" || method === "thread/resume") { + if (method === "thread/start") { + write({ id, result: fixture.responses.threadStart }); + return; + } + if (method === "thread/resume") { + if (script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + } + const threadId = message.params?.threadId; + const childSnapshot = script.childResumeSnapshots?.[threadId]; + if (script.resumeRequestMarker) { + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { + threadId: script.rootThreadId, + requestId: script.resumeRequestMarker, + }, + }); + } + if (childSnapshot?.hang) { + return; + } + if (childSnapshot?.error) { + write({ id, error: { code: -32000, message: childSnapshot.error } }); + return; + } + if (childSnapshot) { + write({ + id, + result: { + ...fixture.responses.threadStart, + model: childSnapshot.model, + reasoningEffort: childSnapshot.reasoningEffort, + thread: { + ...fixture.responses.threadStart.thread, + id: threadId, + sessionId: threadId, + }, + }, + }); + for (const notification of childSnapshot.notifications ?? []) { + write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); + } + return; + } write({ id, result: fixture.responses.threadStart }); return; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3d907e8b3..726cfbff0a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,6 +10,7 @@ import { AuthTokenExchangeGrantType, CommandId, DEFAULT_SERVER_SETTINGS, + type DpopFailureReason, EnvironmentId, EventId, GitCommandError, @@ -57,6 +58,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -116,6 +118,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -413,6 +416,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + threadDeletionReactor?: Partial; analyticsService?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -790,13 +794,20 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - latestSequence: Effect.succeed(0), - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(ThreadDeletionReactor)({ + start: () => Effect.void, + drainThrough: () => Effect.void, + ...options?.layers?.threadDeletionReactor, + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ @@ -1119,6 +1130,7 @@ const exchangeAccessToken = ( readonly _tag?: string; readonly code?: string; readonly reason?: string; + readonly dpopFailureReason?: DpopFailureReason; readonly traceId?: string; }>(response); return { @@ -1801,6 +1813,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports clock skew for a future-dated DPoP token exchange proof", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000) + 25, + }); + + const exchange = yield* exchangeAccessToken(credential.credential, { + headers: { dpop: dpop.proof }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(exchange.response.status, 401); + assert.equal(exchange.body._tag, "EnvironmentAuthInvalidError"); + assert.equal(exchange.body.code, "auth_invalid"); + assert.equal(exchange.body.reason, "invalid_credential"); + assert.equal(exchange.body.dpopFailureReason, "time_window"); + assert.equal(typeof exchange.body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects replayed DPoP proofs across token exchanges", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1850,6 +1894,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(replayBootstrap.body.code, "auth_invalid"); assert.equal(replayBootstrap.body.reason, "invalid_credential"); + assert.equal(replayBootstrap.body.dpopFailureReason, "replay"); assert.equal(typeof replayBootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1925,6 +1970,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(bootstrap.body.code, "auth_invalid"); assert.equal(bootstrap.body.reason, "invalid_credential"); + assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch"); assert.equal(typeof bootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4583,6 +4629,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.equal(streamedResponse.status, 204); yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + + const uploadedFile = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const fileResponse = yield* HttpClient.post(uploadedFile.relativeUrl, { + body: HttpBody.stream( + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + "application/pdf", + ), + }); + assert.equal(fileResponse.status, 204); + const uploadedFilePath = path.join( + config.attachmentsDir, + `${uploadedFile.attachmentId}.pdf`, + ); + assert.isTrue(yield* fileSystem.exists(uploadedFilePath)); + + // A mint that carries the attachment's display name and mime + // serves a real download filename and Content-Type. + const download = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { + _tag: "attachment", + attachmentId: uploadedFile.attachmentId, + fileName: "report.pdf", + mimeType: "application/pdf", + }, + }); + const downloadResponse = yield* HttpClient.get(download.relativeUrl); + assert.equal(downloadResponse.status, 200); + assert.equal( + downloadResponse.headers["content-disposition"], + 'attachment; filename="report.pdf"', + ); + assert.equal(downloadResponse.headers["content-type"], "application/pdf"); + + // Old clients mint without name or mime and still get a download. + const bareDownload = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId: uploadedFile.attachmentId }, + }); + const bareResponse = yield* HttpClient.get(bareDownload.relativeUrl); + assert.equal(bareResponse.status, 200); + assert.equal(bareResponse.headers["content-disposition"], "attachment"); + assert.equal(bareResponse.headers["content-type"], "application/octet-stream"); + + yield* client[WS_METHODS.attachmentsDelete]({ + attachmentId: uploadedFile.attachmentId, + }); + assert.isFalse(yield* fileSystem.exists(uploadedFilePath)); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects an over-limit chunked upload through the route without hanging", () => + Effect.gen(function* () { + const config = yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + type: "file", + name: "big.bin", + mimeType: "application/octet-stream", + sizeBytes: 6, + }); + const NodeHttp = yield* Effect.promise(() => import("node:http")); + const uploadUrl = new URL(issued.relativeUrl, yield* getHttpServerUrl()); + const status = yield* Effect.callback((resume) => { + let completed = false; + const complete = (result: Effect.Effect) => { + if (completed) return; + completed = true; + resume(result); + }; + const request = NodeHttp.request( + uploadUrl, + { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "transfer-encoding": "chunked", + }, + }, + (response) => { + request.end(); + response.resume(); + response.once("end", () => complete(Effect.succeed(response.statusCode ?? 0))); + response.once("error", (error) => complete(Effect.fail(error))); + }, + ); + request.once("error", (error) => complete(Effect.fail(error))); + request.flushHeaders(); + request.write(new Uint8Array(4), () => { + request.write(new Uint8Array(4)); + }); + + return Effect.sync(() => request.destroy()); + }); + assert.equal(status, 400); + assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), []); }), ), ); @@ -4770,6 +4923,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("refreshes providers for each subscribeServerConfig connection", () => + Effect.gen(function* () { + const refreshCalls = yield* Ref.make(0); + const firstRefreshDone = yield* Deferred.make(); + const secondRefreshDone = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + refresh: () => + Ref.updateAndGet(refreshCalls, (count) => count + 1).pipe( + Effect.tap((count) => + Deferred.succeed( + count === 1 ? firstRefreshDone : secondRefreshDone, + undefined, + ).pipe(Effect.ignore), + ), + Effect.as([]), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); + yield* Deferred.await(firstRefreshDone); + }), + ), + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); + yield* Deferred.await(secondRefreshDone); + }), + ), + ); + + assert.equal(yield* Ref.get(refreshCalls), 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -5265,7 +5463,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }) as const; const wsUrl = yield* getWsServerUrl( - "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro", + "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro&connectionMethod=relay", ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => @@ -5302,15 +5500,144 @@ it.layer(NodeServices.layer)("server router seam", (it) => { { surface: "mobile", appVersion: "1.2.3", + clientAppVersion: "1.2.3", + clientOs: "iOS", os: "iOS", + clientDeviceType: "phone", osMajorVersion: 18, + clientOsMajorVersion: 18, deviceModel: "iPhone 15 Pro", + clientDeviceModel: "iPhone 15 Pro", + connectionMethod: "relay", + }, + { + surface: "mobile", + appVersion: "1.2.3", + clientAppVersion: "1.2.3", + clientOs: "iOS", + os: "iOS", + clientDeviceType: "phone", + osMajorVersion: 18, + clientOsMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + clientDeviceModel: "iPhone 15 Pro", + connectionMethod: "relay", }, - { surface: "mobile", appVersion: "1.2.3" }, ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps telemetry separate for simultaneous clients", () => + Effect.gen(function* () { + const analyticsEvents: Array<{ + event: string; + properties: Readonly> | undefined; + }> = []; + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => analyticsEvents.push({ event, properties })), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 1 }), + }, + }, + }); + + const webUrl = yield* getWsServerUrl( + "/ws?clientSurface=web&clientAppVersion=2.0.0&clientDeviceType=desktop&clientOs=Windows&clientWebDeployment=hosted&clientBrowser=Chrome&connectionMethod=direct", + ); + const mobileUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=3.0.0&clientDeviceType=tablet&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+Tablet&connectionMethod=relay", + ); + const turnCommand = (client: string) => ({ + type: "thread.turn.start" as const, + commandId: CommandId.make(`cmd-${client}-turn`), + threadId: ThreadId.make(`thread-${client}`), + message: { + messageId: MessageId.make(`message-${client}`), + role: "user" as const, + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.scoped( + withWsRpcClient(webUrl, (webClient) => + withWsRpcClient(mobileUrl, (mobileClient) => + Effect.gen(function* () { + yield* mobileClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("mobile")); + yield* webClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("web")); + }), + ), + ), + ); + + assert.deepEqual( + analyticsEvents + .filter(({ event }) => event === "client.turn.requested") + .map(({ properties }) => properties), + [ + { + surface: "mobile", + appVersion: "3.0.0", + clientAppVersion: "3.0.0", + clientOs: "Android", + os: "Android", + clientDeviceType: "tablet", + osMajorVersion: 15, + clientOsMajorVersion: 15, + deviceModel: "Pixel Tablet", + clientDeviceModel: "Pixel Tablet", + connectionMethod: "relay", + }, + { + surface: "web", + appVersion: "2.0.0", + clientAppVersion: "2.0.0", + clientOs: "Windows", + clientDeviceType: "desktop", + webDeployment: "hosted", + clientBrowser: "Chrome", + connectionMethod: "direct", + }, + ], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores invalid client telemetry without rejecting the connection", () => + Effect.gen(function* () { + const connectedProperties: Array> | undefined> = []; + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + event === "client.connected" + ? Effect.sync(() => connectedProperties.push(properties)) + : Effect.void, + }, + }, + }); + + const invalidUrl = yield* getWsServerUrl( + "/ws?clientSurface=watch&clientDeviceType=television&clientOs=Plan9&clientWebDeployment=cdn&clientBrowser=&clientOsMajorVersion=-1&connectionMethod=teleport", + ); + yield* Effect.scoped( + withWsRpcClient(invalidUrl, (client) => client[WS_METHODS.serverGetSettings]({})), + ); + + assert.deepEqual(connectedProperties, [{}]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -8238,6 +8565,111 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("drains deletion cleanup through the re-created thread event", () => + Effect.gen(function* () { + // A draft retry reuses the thread id its failed bootstrap deleted. The + // deletion reactor stops sessions and closes terminals by that id, so + // both thread.create paths use the created event as a fence, then drain + // cleanup before handing the new incarnation to resource-owning work. + const trace: Array = []; + const drainRequested = yield* Deferred.make(); + const cleanupDone = yield* Deferred.make(); + yield* buildAppUnderTest({ + layers: { + threadDeletionReactor: { + drainThrough: (sequence) => + Effect.gen(function* () { + trace.push(`drain:${sequence}`); + yield* Deferred.succeed(drainRequested, undefined); + yield* Deferred.await(cleanupDone); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + trace.push(command.type); + return { sequence: trace.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-retry-after-delete"); + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const directCreate = yield* Effect.forkChild( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("cmd-retry-create"), + threadId, + projectId: defaultProjectId, + title: "Retry", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }), + ); + yield* Deferred.await(drainRequested); + assert.deepEqual(trace, ["thread.create", "drain:1"]); + yield* Deferred.succeed(cleanupDone, undefined); + yield* Fiber.join(directCreate); + }), + ), + ); + assert.deepEqual(trace, ["thread.create", "drain:1"]); + + // Cleanup is already released; the bootstrap path must still drain + // between creating the thread and starting its turn. + trace.length = 0; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const bootstrapCreate = yield* Effect.forkChild( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-retry-bootstrap"), + threadId, + message: { + messageId: MessageId.make("msg-retry-bootstrap"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Retry", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + runSetupScript: false, + }, + createdAt, + }), + ); + yield* Fiber.join(bootstrapCreate); + }), + ), + ); + assert.deepEqual(trace, ["thread.create", "drain:1", "thread.turn.start"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not report a deleted bootstrap thread when cleanup fails", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index afdd06f4ea..a8a77269c2 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; @@ -20,6 +21,11 @@ interface RecordedBatchRequest { readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly serverOs?: string; + readonly serverArch?: string; + readonly serverAppVersion?: string; + readonly serverMode?: string; + readonly t3CodeVersion?: string; }; }>; } | null; @@ -31,6 +37,11 @@ interface RecordedBatchBody { readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly serverOs?: string; + readonly serverArch?: string; + readonly serverAppVersion?: string; + readonly serverMode?: string; + readonly t3CodeVersion?: string; }; }>; } @@ -71,6 +82,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ); const runtimeLayer = telemetryLayer.pipe( Layer.provide(configLayer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "arm64"), + ), + ), Layer.provideMerge(NodeHttpServer.layerTest), ); @@ -117,6 +134,18 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ), true, ); + assert.equal( + batchRequests.every((request) => + request.body.batch.every( + (event) => + event.properties?.serverOs === "Linux" && + event.properties.serverArch === "arm64" && + event.properties.serverAppVersion === event.properties.t3CodeVersion && + event.properties.serverMode === "web", + ), + ), + true, + ); }), ); }); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb1..423ba8d86f 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -7,6 +7,7 @@ * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import type { ClientOs } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -66,6 +67,21 @@ export class AnalyticsService extends Context.Service< ); } +export function serverOsFromNodePlatform(platform: string): ClientOs { + switch (platform) { + case "darwin": + return "macOS"; + case "win32": + return "Windows"; + case "linux": + return "Linux"; + case "android": + return "Android"; + default: + return "other"; + } +} + export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; @@ -121,6 +137,11 @@ export const make = Effect.gen(function* () { arch: hostArchitecture, t3CodeVersion: packageJson.version, clientType, + serverOs: serverOsFromNodePlatform(hostPlatform), + serverArch: hostArchitecture, + serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), + serverAppVersion: packageJson.version, + serverMode: serverConfig.mode, }, timestamp: event.capturedAt, })), diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts deleted file mode 100644 index f4ac951653..0000000000 --- a/apps/server/src/terminal/PtyAdapter.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); - -describe("PtySpawnError", () => { - it("derives messages from structural context while preserving the full cause chain", () => { - const spawnCause = new Error("spawn /bin/zsh ENOENT"); - const adapterError = new PtyAdapter.PtySpawnError({ - adapter: "node-pty", - shell: "/bin/zsh", - cause: spawnCause, - }); - const managerError = new PtyAdapter.PtySpawnError({ - adapter: "terminal-manager", - attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], - cause: adapterError, - }); - - assert(isPtySpawnError(managerError)); - assert.strictEqual( - managerError.message, - "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", - ); - assert.strictEqual( - adapterError.message, - "Failed to spawn PTY process '/bin/zsh' with node-pty.", - ); - assert.strictEqual(managerError.cause, adapterError); - assert.strictEqual(adapterError.cause, spawnCause); - }); -}); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e22..0b24b260ca 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -26,6 +27,7 @@ import { import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; @@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); + const requestedReasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); yield* applyGrokAcpModelSelection({ runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), requestedModelId: resolvedModel, + requestedReasoningEffort, mapError: (cause) => new TextGenerationError({ operation, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c7..39c1031727 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -11,6 +11,7 @@ import { beforeEach, expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; import * as TextGeneration from "./TextGeneration.ts"; @@ -18,8 +19,11 @@ const runtimeMock = { state: { startCalls: [] as string[], promptUrls: [] as string[], + promptParts: [] as ReadonlyArray[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateCalls: 0, + connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, promptRequestError: undefined as unknown, @@ -30,8 +34,11 @@ const runtimeMock = { reset() { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; + this.state.promptParts.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateCalls = 0; + this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; this.state.promptRequestError = undefined; @@ -40,7 +47,7 @@ const runtimeMock = { }; const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => + startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; const url = `http://127.0.0.1:${4_300 + index}`; @@ -52,29 +59,51 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { runtimeMock.state.closeCalls.push(url); }), ); + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); return { url, + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + runtimeMock.state.connectionError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }), + ) + : Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", + exitCode: null, + external: Boolean(serverUrl), + }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { create: async () => { + runtimeMock.state.sessionCreateCalls += 1; if (runtimeMock.state.sessionCreateError !== undefined) { throw runtimeMock.state.sessionCreateError; } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async () => { + prompt: async (input: { readonly parts: ReadonlyArray }) => { runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -160,18 +189,35 @@ const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", }); +const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverPassword: "secret-password", +}); const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }); +const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", +}); function withOpenCodeTextGeneration( settings: OpenCodeSettings, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + environment?: NodeJS.ProcessEnv, ) { return Effect.gen(function* () { - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: process.cwd(), + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -187,6 +233,88 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("excludes generic files from thread title generation", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], + }, + }; + + yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Review these attachments.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + attachments: [ + { + type: "image", + id: "thread-image-attachment", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + }, + { + type: "file", + id: "thread-report-attachment-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }); + + expect(runtimeMock.state.promptParts[0]).toEqual([ + expect.objectContaining({ type: "text" }), + expect.objectContaining({ type: "file", filename: "screenshot.png" }), + ]); + }), + ), + ); + + it.effect("passes configured authentication to a locally spawned server", () => + withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ), + ); + + it.effect("uses an environment-only password for a locally spawned server", () => + withOpenCodeTextGeneration( + DEFAULT_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:environment-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + + it.effect("uses settings auth when the local environment password differs", () => + withOpenCodeTextGeneration( + LOCAL_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { @@ -418,6 +546,36 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { it.layer(OpenCodeTextGenerationExistingServerTestLayer)( "OpenCodeTextGeneration with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + withOpenCodeTextGeneration( + EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + expect(runtimeMock.state.authHeaders).toEqual([null]); + }), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ), + ); + + it.effect("does not create a session when the server version is unsupported", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("v1.14.18 is too old"); + expect(runtimeMock.state.sessionCreateCalls).toBe(0); + }), + ), + ); + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cf..e0e960422b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,9 +1,5 @@ import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; import { NonNegativeInt, @@ -31,8 +27,7 @@ import { sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", @@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str .trim(); } -interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeRuntime.OpenCodeServerProcess | null; - /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. - */ - serverScope: Scope.Closeable | null; - binaryPath: string | null; - activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; -} - export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, - environment?: NodeJS.ProcessEnv, ) { const serverConfig = yield* ServerConfig.ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const sharedServerMutex = yield* Semaphore.make(1); - const sharedServerState: SharedOpenCodeTextGenerationServerState = { - server: null, - serverScope: null, - binaryPath: null, - activeRequests: 0, - idleCloseFiber: null, - }; - - const closeSharedServer = Effect.fn("closeSharedServer")(function* () { - const scope = sharedServerState.serverScope; - sharedServerState.server = null; - sharedServerState.serverScope = null; - sharedServerState.binaryPath = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { - const idleCloseFiber = sharedServerState.idleCloseFiber; - sharedServerState.idleCloseFiber = null; - if (idleCloseFiber !== null) { - yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); - } - }); - - const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeRuntime.OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - Effect.forkIn(idleFiberScope), - ); - sharedServerState.idleCloseFiber = fiber; - }); - - const acquireSharedServer = (input: { - readonly binaryPath: string; - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - }) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - - const existingServer = sharedServerState.server; - if (existingServer !== null) { - if ( - sharedServerState.binaryPath !== input.binaryPath && - sharedServerState.activeRequests === 0 - ) { - yield* closeSharedServer(); - } else { - if (sharedServerState.binaryPath !== input.binaryPath) { - yield* Effect.logWarning( - "OpenCode shared server binary path mismatch: requested " + - input.binaryPath + - " but active server uses " + - sharedServerState.binaryPath + - "; reusing existing server because there are active requests", - ); - } - sharedServerState.activeRequests += 1; - return existingServer; - } - } - - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. - // - // The `Scope.make` / spawn / record-or-close transitions run inside - // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by - // `Effect.exit` and drives us through the failure branch that - // closes the fresh scope. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - restore( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - environment: resolvedEnvironment, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } - - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; - }), - ); - }), - ); - - const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server) { - return; - } - sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); - if (sharedServerState.activeRequests === 0) { - yield* scheduleIdleClose(server); - } - }), - ); - - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. - yield* Effect.addFinalizer(() => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - sharedServerState.activeRequests = 0; - yield* closeSharedServer(); - }), - ), - ); + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { readonly operation: OpenCodeTextGenerationOperation; @@ -375,19 +194,22 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments, + attachments: input.attachments?.filter((attachment) => attachment.type === "image"), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { + function* ( + server: Pick< + OpenCodeRuntime.OpenCodeServerConnection, + "url" | "serverPassword" | "version" + >, + ) { const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ try: () => @@ -496,17 +318,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const rawOutput = + const serverOutput = openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) - : yield* Effect.acquireUseRelease( - acquireSharedServer({ + ? openCodeRuntime + .connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, + directory: input.cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) + : serverOwner.withServer(runAgainstServer); + const rawOutput = yield* serverOutput.pipe( + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => + Effect.fail( + new TextGenerationError({ operation: input.operation, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + cause, }), - runAgainstServer, - releaseSharedServer, - ); + ), + }), + ); const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac97..224662e9dc 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,9 +1,9 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files rather than T3 Code's - * orchestration projections, so usage covers turns driven outside T3 Code too. - * This is the approach `ccusage` takes. + * The scan reads the provider CLIs' own session files (Claude Code, Codex, and + * Grok Build) rather than T3 Code's orchestration projections, so usage covers + * turns driven outside T3 Code too. This is the approach `ccusage` takes. * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm @@ -21,6 +21,7 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -34,6 +35,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -123,6 +125,7 @@ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; + const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -218,10 +221,22 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. + // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. + const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; + const grokHome = + grokHomeEnv.length > 0 + ? path.resolve(expandHomePath(grokHomeEnv)) + : path.join(NodeOS.homedir(), ".grok"); return [ { provider: "claude" as const, dir: claudeDir }, { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { + provider: "grok" as const, + dir: path.join(grokHome, "sessions"), + fileName: "updates.jsonl", + }, ]; }); @@ -353,7 +368,7 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir } of dirs) { + for (const { provider, dir, fileName } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) @@ -373,7 +388,9 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c0..1f0365f3e7 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -42,12 +42,21 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); + original.set("/grok.jsonl", { + size: 40, + mtimeMs: 300, + provider: "grok", + records: [ + record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), + ], + }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(2); + expect(restored.size).toBe(3); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); }); it("interns repeated model and session strings", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee..02daf5ebbd 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db..33aef8fae2 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -22,6 +22,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, type UsageRecord, } from "./usageTranscripts.ts"; @@ -37,12 +38,18 @@ export interface TranscriptFile { * Errors on individual entries are swallowed: session files rotate and get * removed while the walk is in flight, and a partial listing is far better than * failing the page. + * + * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). + * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that + * never carry usage, so the basename filter keeps a cold scan off those files. */ export async function listTranscriptFiles( root: string, sinceMs: number, + options?: { readonly fileName?: string }, ): Promise { const found: TranscriptFile[] = []; + const fileName = options?.fileName; const walk = async (dir: string): Promise => { let entries; @@ -57,7 +64,11 @@ export async function listTranscriptFiles( await walk(child); continue; } - if (!entry.name.endsWith(".jsonl")) continue; + if (fileName !== undefined) { + if (entry.name !== fileName) continue; + } else if (!entry.name.endsWith(".jsonl")) { + continue; + } try { const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { @@ -129,6 +140,12 @@ export async function readTranscriptRecords( continue; } + if (provider === "grok") { + if (!mightCarryUsage(line, provider)) continue; + for (const grokRecord of parseGrokLine(line)) records.push(grokRecord); + continue; + } + if (!mightCarryUsage(line, provider)) continue; const record = parseClaudeLine(line); if (record !== null) records.push(record); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836..b09db613ed 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { + GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -249,3 +251,316 @@ describe("totalTokens", () => { ).toBe(100); }); }); + +describe("parseGrokLine", () => { + /** Shaped after a real Grok Build `turn_completed` session update. */ + function turnCompleted(overrides?: { + sessionId?: string; + promptId?: string; + timestamp?: number; + agentTimestampMs?: number; + usage?: Record; + modelUsage?: Record> | null; + }): string { + const modelUsage = + overrides && "modelUsage" in overrides + ? overrides.modelUsage + : { + "grok-4.5-build": { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + }, + }; + + return JSON.stringify({ + timestamp: overrides?.timestamp ?? 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", + update: { + sessionUpdate: "turn_completed", + prompt_id: overrides?.promptId ?? "prompt-1", + stop_reason: "end_turn", + usage: { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + ...(modelUsage === null ? {} : { modelUsage }), + ...overrides?.usage, + }, + }, + _meta: { + eventId: "event-1", + agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, + }, + }, + }); + } + + it("extracts per-model totals and provider-reported cost ticks", () => { + const records = parseGrokLine(turnCompleted()); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok-4.5-build"); + expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); + expect(record?.timestampMs).toBe(1_786_372_566_485); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); + }); + + it("emits one record per model when modelUsage has several entries", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 1000, + outputTokens: 50, + cachedReadTokens: 400, + reasoningTokens: 20, + costUsdTicks: 50_000_000, + }, + "grok-composer-2.5-fast": { + inputTokens: 200, + outputTokens: 30, + cachedReadTokens: 100, + reasoningTokens: 0, + costUsdTicks: 10_000_000, + }, + }, + }), + ); + + expect(records.map((record) => record.model).toSorted()).toEqual([ + "grok-4.5", + "grok-composer-2.5-fast", + ]); + expect(records.every((record) => record.provider === "grok")).toBe(true); + expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( + 0.005, + 12, + ); + }); + + it("inherits top-level cost ticks for a single model without its own ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 1000, + outputTokens: 10, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.reportedCostUsd).toBe(1); + }); + + it("falls back to a generic grok model when modelUsage is absent", () => { + const records = parseGrokLine(turnCompleted({ modelUsage: null })); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); + }); + + it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "empty-sibling": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("does not invent a colliding dedupe key when prompt_id is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); + }); + + it("ignores non-turn lines and empty usage", () => { + expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); + expect(parseGrokLine("not json")).toEqual([]); + expect( + parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + }), + ), + ).toEqual([]); + }); + + it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + const records = parseGrokLine(line); + expect(records[0]?.timestampMs).toBe(1_786_372_566_000); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935c..2aea607096 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -1,8 +1,8 @@ /** * Pure parsers for the provider CLIs' on-disk session transcripts. * - * Both parsers are line-at-a-time reducers so callers can stream large files - * without materialising them. Neither touches the filesystem. + * Each parser is a line-at-a-time reducer so callers can stream large files + * without materialising them. None of them touch the filesystem. * * @module usageTranscripts */ @@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "grok") return line.includes('"turn_completed"'); + return line.includes('"token_count"'); +} + +/** + * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok + * headless `total_cost_usd_ticks`. Convert to dollars for pricing. + */ +export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; + +export function grokCostTicksToUsd(ticks: unknown): number | null { + if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; + return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; } /* -------------------------------------------------------------------------- */ @@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Grok Build */ +/* -------------------------------------------------------------------------- */ + +interface GrokUsageTotals { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cachedReadTokens: number; + readonly cacheCreationTokens: number; + readonly reasoningTokens: number; + readonly costUsdTicks: number | null; +} + +function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + return { + inputTokens: int(record["inputTokens"]), + outputTokens: int(record["outputTokens"]), + cachedReadTokens: int(record["cachedReadTokens"]), + cacheCreationTokens: int(record["cacheCreationTokens"]), + reasoningTokens: int(record["reasoningTokens"]), + costUsdTicks: + typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) + ? record["costUsdTicks"] + : null, + }; +} + +function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { + const cachedInputTokens = totals.cachedReadTokens; + const cacheCreationTokens = totals.cacheCreationTokens; + // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. + const uncachedInputTokens = Math.max( + 0, + totals.inputTokens - cachedInputTokens - cacheCreationTokens, + ); + const outputTokens = totals.outputTokens; + return { + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), + }; +} + +/** + * Parses one line of a Grok Build `updates.jsonl` session log. + * + * Usage lands on `turn_completed` session updates. Per-model breakdowns live + * under `usage.modelUsage`; when present each model becomes its own record. + * + * Returns every record for the line (0 or more). Callers stream line-by-line + * and flatten. + */ +export function parseGrokLine(line: string): readonly UsageRecord[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) return []; + + const record = parsed as Record; + const params = record["params"]; + if (typeof params !== "object" || params === null) return []; + const paramsRecord = params as Record; + + const update = paramsRecord["update"]; + if (typeof update !== "object" || update === null) return []; + const updateRecord = update as Record; + if (updateRecord["sessionUpdate"] !== "turn_completed") return []; + + const usage = updateRecord["usage"]; + if (typeof usage !== "object" || usage === null) return []; + const usageRecord = usage as Record; + + const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; + const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; + + // Prefer the high-resolution agent clock; fall back to the outer unix seconds. + const meta = paramsRecord["_meta"]; + let timestampMs: number | null = null; + if (typeof meta === "object" && meta !== null) { + const agentTimestampMs = (meta as Record)["agentTimestampMs"]; + if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { + timestampMs = agentTimestampMs; + } + } + if (timestampMs === null) { + const timestamp = record["timestamp"]; + if (typeof timestamp === "number" && Number.isFinite(timestamp)) { + timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; + } + } + if (timestampMs === null) return []; + + const topLevel = readGrokUsageTotals(usageRecord); + if (topLevel === null) return []; + + const modelUsage = usageRecord["modelUsage"]; + const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; + if (typeof modelUsage === "object" && modelUsage !== null) { + for (const [model, raw] of Object.entries(modelUsage as Record)) { + if (model.length === 0) continue; + const totals = readGrokUsageTotals(raw); + if (totals === null) continue; + modelEntries.push({ model, totals }); + } + } + + if (modelEntries.length === 0) { + if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; + return [ + { + provider: "grok", + timestampMs, + model: "grok", + sessionId, + totals: grokTotalsToUsage(topLevel), + reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), + // No prompt id means we cannot tell two same-second updates apart. + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, + }, + ]; + } + + // Cost allocation: + // 1. Emitted models with their own costUsdTicks keep those values. + // 2. Remaining aggregate cost (top-level minus those per-model ticks, + // clamped at 0) is pro-rated across emitted models that lack ticks, + // by token share among the unticked models only. + // 3. When no model has per-model ticks, remaining equals the full + // aggregate and every emitted model gets a token-share slice. + // Zero-token rows are never emitted and never count toward used ticks. + const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); + let usedTickedCostUsd = 0; + let untickedTokenDenominator = 0; + for (const entry of modelEntries) { + const tokens = totalTokens(grokTotalsToUsage(entry.totals)); + if (tokens === 0) continue; + if (entry.totals.costUsdTicks !== null) { + usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; + } else { + untickedTokenDenominator += tokens; + } + } + const remainingCostUsd = + topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); + + const results: UsageRecord[] = []; + for (const entry of modelEntries) { + const totals = grokTotalsToUsage(entry.totals); + if (totalTokens(totals) === 0) continue; + + let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); + if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { + reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); + } + + results.push({ + provider: "grok", + timestampMs, + model: entry.model, + sessionId, + totals, + reportedCostUsd, + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, + }); + } + return results; +} + export { EMPTY_TOTALS }; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8a820aa819..587a3e4abb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -759,18 +759,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(error.detail, "Git command failed in"); }), ); - - it.effect("treats removing an already-gone worktree as a no-op", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - - yield* driver.removeWorktree({ cwd, path: missingWorktree }); - }), - ); }); describe("review diff previews", () => { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6618f05cf7..73d1b0fd9c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -15,7 +15,11 @@ import { type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, + ClientConnectionMethod, + ClientDeviceType, + ClientOs, ClientSurface, + ClientWebDeployment, CommandId, type DiscoveredLocalServerList, EventId, @@ -82,6 +86,7 @@ import { } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -369,7 +374,12 @@ function toAuthAccessStreamEvent( } const isClientSurface = Schema.is(ClientSurface); +const isClientConnectionMethod = Schema.is(ClientConnectionMethod); +const isClientDeviceType = Schema.is(ClientDeviceType); +const isClientOs = Schema.is(ClientOs); +const isClientWebDeployment = Schema.is(ClientWebDeployment); const MAX_CLIENT_APP_VERSION_LENGTH = 64; +const MAX_CLIENT_BROWSER_LENGTH = 64; const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; // Optional client identity announced on the /ws upgrade URL next to wsTicket. @@ -392,36 +402,56 @@ function readClientConnectionOrigin( }; } -const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ - ...(origin.surface !== undefined ? { surface: origin.surface } : {}), - ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), -}); - -function readMobileDeviceAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { +// Client telemetry stays in this socket's RPC layer. It must not become a +// server-global "current client" because several client types can connect at once. +function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { const url = HttpServerRequest.toURL(request); - if (Option.isNone(url) || url.value.searchParams.get("clientSurface") !== "mobile") { + if (Option.isNone(url)) { return {}; } + const surface = url.value.searchParams.get("clientSurface"); + const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; + const deviceType = url.value.searchParams.get("clientDeviceType"); const os = url.value.searchParams.get("clientOs"); + const webDeployment = url.value.searchParams.get("clientWebDeployment"); + const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? ""; + const connectionMethod = url.value.searchParams.get("connectionMethod"); const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; const osMajorVersion = Number(rawOsMajorVersion); const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; + const isMobile = surface === "mobile"; + const hasOsMajorVersion = + isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0; + const hasDeviceModel = + isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH; return { - ...(os === "iOS" || os === "Android" ? { os } : {}), - ...(rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0 - ? { osMajorVersion } + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion, clientAppVersion: appVersion } : {}), - ...(deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH - ? { deviceModel } + ...(isClientOs(os) + ? { + clientOs: os, + ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}), + } + : {}), + ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}), + ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}), + ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH + ? { clientBrowser: browser } : {}), + ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}), + ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}), + ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}), }; } const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, + clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], ) => WsRpcGroup.toLayer( @@ -430,6 +460,7 @@ const makeWsRpcLayer = ( const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; // Every command dispatched on this connection carries the connecting // client's origin, including server-generated bootstrap sub-commands: @@ -443,18 +474,17 @@ const makeWsRpcLayer = ( command, hasClientOrigin ? { origin: clientOrigin } : undefined, ); - const originProps = clientOriginAnalyticsProps(clientOrigin); const recordClientCommandAnalytics = (command: OrchestrationCommand) => { switch (command.type) { case "thread.create": - return analytics.record("client.thread.started", originProps); + return analytics.record("client.thread.started", clientAnalyticsProps); case "thread.turn.start": return command.bootstrap?.createThread ? Effect.andThen( - analytics.record("client.thread.started", originProps), - analytics.record("client.turn.requested", originProps), + analytics.record("client.thread.started", clientAnalyticsProps), + analytics.record("client.turn.requested", clientAnalyticsProps), ) - : analytics.record("client.turn.requested", originProps); + : analytics.record("client.turn.requested", clientAnalyticsProps); default: return Effect.void; } @@ -997,7 +1027,7 @@ const makeWsRpcLayer = ( const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* dispatchFromClient({ + const created = yield* dispatchFromClient({ type: "thread.create", commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, @@ -1010,6 +1040,11 @@ const makeWsRpcLayer = ( worktreePath: bootstrap.createThread.worktreePath, createdAt: bootstrap.createThread.createdAt, }); + // The successful create is a fence in the engine command queue: + // every delete for the prior incarnation committed before it. + // Drain through that event before setup or turn start can own + // terminals and provider sessions under the reused thread id. + yield* threadDeletionReactor.drainThrough(created.sequence); createdThread = true; } @@ -1097,6 +1132,14 @@ const makeWsRpcLayer = ( normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) : dispatchFromClient(normalizedCommand).pipe( + Effect.tap(({ sequence }) => + // Returning from thread.create is the handoff point at which + // clients may start resources for the new incarnation. Use + // its event sequence as the exact deletion-cleanup fence. + normalizedCommand.type === "thread.create" + ? threadDeletionReactor.drainThrough(sequence) + : Effect.void, + ), Effect.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command"), ), @@ -2482,23 +2525,29 @@ export const websocketRpcRouteLayer = Layer.unwrap( const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ); const clientOrigin = readClientConnectionOrigin(request); + const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", { - ...clientOriginAnalyticsProps(clientOrigin), - ...readMobileDeviceAnalyticsProps(request), - }); + yield* analytics.record("client.connected", clientAnalyticsProps); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( + makeWsRpcLayer( + session, + clientOrigin, + clientAnalyticsProps, + previewAutomationBroker, + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index e4634f5b98..0000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveAssetUrl } from "./assetUrls"; - -describe("resolveAssetUrl", () => { - it("resolves an environment-relative asset URL", () => { - expect( - resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), - ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); - }); - - it("rejects an invalid environment base URL", () => { - expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index fbf7c14b73..5425bca0b4 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -29,6 +29,8 @@ export function ElectronBrowserHost() { previewState.serverEpoch, snapshot.tabId, ), + pictureInPicture: + previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; @@ -80,7 +82,7 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => { + {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index ae0526abb1..74db764005 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { useActiveBrowserRecordingTabIds } from "./browserRecording"; import { browserViewportSettingKey, resolveBrowserViewportLayout, @@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: { readonly runtimeTabId: string; readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; + readonly pictureInPicture: boolean; readonly zoomFactor: number; }) { - const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props; + const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = + props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: { }; }), ); + const backgroundActivity = useBrowserSurfaceStore( + (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0, + ); + const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { @@ -92,7 +99,6 @@ export function HostedBrowserWebview(props: { const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; - if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); }, []); useEffect(() => { @@ -231,8 +237,10 @@ export function HostedBrowserWebview(props: { if (!config) return null; + const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, + renderingActive, cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, @@ -244,6 +252,7 @@ export function HostedBrowserWebview(props: { className="fixed overflow-hidden bg-muted/35" style={{ ...wrapperStyle, overscrollBehavior: "contain" }} onScroll={syncContentPresentation} + data-preview-rendering={renderingActive ? "active" : "suspended"} data-preview-viewport={runtimeTabId} >
@@ -259,6 +268,12 @@ export function HostedBrowserWebview(props: { { it("starts recording for a visible tab", async () => { await startBrowserRecording("recording-tab"); - - expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + const startupEvents = [...events]; await stopBrowserRecording("recording-tab"); + expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); it("records a hidden tab without requiring it to become visible", async () => { @@ -195,11 +195,11 @@ describe("browser recording", () => { }; await startBrowserRecording("recording-tab"); + const startupEvents = [...events]; expect(startScreencast).toHaveBeenCalledWith("recording-tab"); - expect(events).toEqual(["start-screencast", "publish:recording-tab"]); - await stopBrowserRecording("recording-tab"); + expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); it("fails startup instead of locking a fallback size when no frame arrives", async () => { diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 69297cfdbb..33d4610da1 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet { const activeRecordings = new Map(); let unsubscribeFrames: (() => void) | null = null; +const publishActiveRecordingTabIds = (): void => { + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); +}; + export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; @@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => { unsubscribeFrames?.(); unsubscribeFrames = null; } - appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { - tabIds: new Set(activeRecordings.keys()), - }); + publishActiveRecordingTabIds(); }; const cleanupFailedRecordingStart = async ( @@ -377,6 +381,7 @@ export async function startBrowserRecording( lifecycle: { phase: "starting" }, }; activeRecordings.set(tabId, recording); + publishActiveRecordingTabIds(); try { try { unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); @@ -487,9 +492,6 @@ export async function startBrowserRecording( if (recording.lifecycle.phase === "starting") { recording.lifecycle = { phase: "recording" }; } - appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { - tabIds: new Set(activeRecordings.keys()), - }); return startedAt; } finally { settleStartup?.(); diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 12b34dd4b5..249d3dcb2f 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, + acquireBrowserSurfaceActivity, resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, } from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {} }); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + }); + + it("keeps concurrent background work active until every lease is released", () => { + const first = acquireBrowserSurfaceActivity("background-browser"); + const second = acquireBrowserSurfaceActivity("background-browser"); + + first(); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1); + + second(); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined(); }); it("freezes the source content dimensions for a fitted presentation", () => { diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 43ae0037c0..fe85c9e38b 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation { } interface BrowserSurfaceStoreState { + readonly activityByTabId: Record; readonly byTabId: Record; + readonly acquireActivity: (tabId: string) => () => void; readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; readonly present: ( tabId: string, @@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): left.height === right.height; export const useBrowserSurfaceStore = create()((set) => ({ + activityByTabId: {}, byTabId: {}, + acquireActivity: (tabId) => { + let released = false; + set((state) => ({ + activityByTabId: { + ...state.activityByTabId, + [tabId]: (state.activityByTabId[tabId] ?? 0) + 1, + }, + })); + return () => { + if (released) return; + released = true; + set((state) => { + const count = state.activityByTabId[tabId] ?? 0; + const activityByTabId = { ...state.activityByTabId }; + if (count <= 1) delete activityByTabId[tabId]; + else activityByTabId[tabId] = count - 1; + return { activityByTabId }; + }); + }; + }, claim: (tabId, owner, fitSourceContent) => set((state) => { const current = state.byTabId[tabId]; @@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create()((set) = }), })); +export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) => + useBrowserSurfaceStore.getState().acquireActivity(tabId); + export function acquireBrowserSurface( tabId: string, fitSourceContent = false, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index d0298dcdee..c2c78f4ac6 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + renderingActive: true, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + renderingActive: true, cornerRadius: 12, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, @@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); - it("keeps an inactive webview paintable while moving it offscreen", () => { + it("suspends painting for an inactive webview", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, + renderingActive: false, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 393, height: 852 }, }); @@ -54,6 +57,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { height: 852, zIndex: -1, pointerEvents: "none", + visibility: "hidden", + }); + }); + + it("keeps an active background task paintable offscreen", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: true, + rect: null, + hiddenSize: { width: 1280, height: 800 }, + }); + + expect(style).toEqual({ + left: HIDDEN_BROWSER_WEBVIEW_OFFSET, + top: HIDDEN_BROWSER_WEBVIEW_OFFSET, + width: 1280, + height: 800, + zIndex: -1, + pointerEvents: "none", visibility: "visible", }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f96f4af046..1e6ff0beab 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -13,18 +13,19 @@ export interface HostedBrowserWebviewWrapperStyle { readonly zIndex: number; readonly pointerEvents: "auto" | "none"; readonly borderRadius?: number; - readonly visibility?: "visible"; + readonly visibility?: "hidden" | "visible"; } export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; + readonly renderingActive: boolean; readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, cornerRadius = 0, hiddenSize, rect } = input; + const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; if (active && rect) { return { left: rect.x, @@ -44,9 +45,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: hiddenSize.height, zIndex: -1, pointerEvents: "none", - // Keep the guest CSS-visible even while physically offscreen. Electron - // webviews can keep metadata/status alive under `visibility:hidden` while - // CDP Runtime/Input commands stall, which breaks offscreen automation. - visibility: "visible", + visibility: renderingActive ? "visible" : "hidden", }; } diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a245cbc54d..a267a6e075 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -19,13 +19,12 @@ import { type RelayClientDeviceRecord, type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, - type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; import { readPrimaryEnvironmentDescriptor, @@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is( ]), ); -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d1efd33014..52da4dde58 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -11,7 +11,13 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; +import { + type ChatMessage, + isImageAttachment, + type SessionPhase, + type Thread, + type ThreadShell, +} from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -278,7 +284,7 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { return; } for (const attachment of message.attachments) { - if (attachment.type !== "image") { + if (!isImageAttachment(attachment)) { continue; } revokeBlobPreviewUrl(attachment.previewUrl); @@ -291,7 +297,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ } const previewUrls: string[] = []; for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; + if (!isImageAttachment(attachment)) continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8d1d812aa4..c09e5e706e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,6 +129,7 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + isImageAttachment, type SessionPhase, type Thread, type TurnDiffSummary, @@ -1262,7 +1263,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); - const { settleThread } = useThreadActions(); + const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -2626,7 +2627,7 @@ function ChatViewContent(props: ChatViewProps) { } const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], + isImageAttachment(attachment) && attachment.previewUrl ? [attachment.previewUrl] : [], ); if ( serverPreviewUrls.length === 0 || @@ -2709,7 +2710,7 @@ function ChatViewContent(props: ChatViewProps) { let changed = false; let imageIndex = 0; const attachments = message.attachments.map((attachment) => { - if (attachment.type !== "image") { + if (!isImageAttachment(attachment)) { return attachment; } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; @@ -4535,6 +4536,8 @@ function ChatViewContent(props: ChatViewProps) { ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; + const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = @@ -5378,6 +5381,27 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "thread.pin") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsPinning) return; + const pinned = activeThreadPinned; + void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( + (result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: pinned ? "Failed to unpin thread" : "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -5482,6 +5506,7 @@ function ChatViewContent(props: ChatViewProps) { activeRightPanelSurface, addTerminalSurface, activeThreadRef, + activeThreadPinned, activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, @@ -5497,8 +5522,11 @@ function ChatViewContent(props: ChatViewProps) { handleUnsettleActiveThread, isServerThread, onToggleDiff, + pinThread, settleThread, + supportsPinning, supportsSettlement, + confirmAndUnpinThread, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -6947,6 +6975,7 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, + { explicit: true }, ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1bb1f44e54..b14d21772c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,9 +6,11 @@ import { buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, + reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, @@ -797,6 +799,69 @@ describe("searchSidebarThreadsByTitle", () => { }); }); +describe("filterSidebarProjectScopeItems", () => { + const items = [ + { value: "all", label: "All projects" }, + { value: "alpha", label: "Alpha workspace" }, + { value: "beta", label: "Beta tools" }, + ] as const; + const filter = (activeScopeKey: string | null, query: string) => + filterSidebarProjectScopeItems({ + items, + activeScopeKey, + query, + matches: (item, candidate) => + item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), + }); + + it("omits the reset row when the sidebar is already unscoped", () => { + expect(filter(null, "")).toEqual(items.slice(1)); + }); + + it("shows the reset row first while a project scope is active", () => { + expect(filter("alpha", "")).toEqual(items); + }); + + it("hides the reset row while filtering an active scope", () => { + expect(filter("alpha", "all")).toEqual([]); + }); + + it("returns matching projects in source order and supports no-match results", () => { + expect(filter(null, "WORK")).toEqual([items[1]]); + expect(filter(null, "missing")).toEqual([]); + }); +}); + +describe("reduceSidebarProjectScopeMenuState", () => { + const queriedOpenState = { open: true, query: "alpha" }; + + it("clears the query when the combobox closes through onOpenChange", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "open-changed", + open: false, + }), + ).toEqual({ open: false, query: "" }); + }); + + it("clears the query when project settings closes the combobox", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "project-settings-opened", + }), + ).toEqual({ open: false, query: "" }); + }); + + it("keeps the popup open while the query changes", () => { + expect( + reduceSidebarProjectScopeMenuState( + { open: true, query: "" }, + { type: "query-changed", query: "beta" }, + ), + ).toEqual({ open: true, query: "beta" }); + }); +}); + describe("sortThreadsForSidebar", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4d87f90d02..e286d21858 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -583,6 +583,44 @@ export function searchSidebarThreadsByTitle thread.title.toLowerCase().includes(normalizedQuery)); } +export function filterSidebarProjectScopeItems(input: { + items: readonly TItem[]; + activeScopeKey: string | null; + query: string; + matches: (item: TItem, query: string) => boolean; +}): readonly TItem[] { + const projectItems = input.items.filter((item) => item.value !== "all"); + const query = input.query.trim(); + if (query.length > 0) { + return projectItems.filter((item) => input.matches(item, query)); + } + return input.activeScopeKey === null ? projectItems : input.items; +} + +export interface SidebarProjectScopeMenuState { + readonly open: boolean; + readonly query: string; +} + +export type SidebarProjectScopeMenuAction = + | { readonly type: "query-changed"; readonly query: string } + | { readonly type: "open-changed"; readonly open: boolean } + | { readonly type: "project-settings-opened" }; + +export function reduceSidebarProjectScopeMenuState( + state: SidebarProjectScopeMenuState, + action: SidebarProjectScopeMenuAction, +): SidebarProjectScopeMenuState { + switch (action.type) { + case "query-changed": + return { ...state, query: action.query }; + case "open-changed": + return { open: action.open, query: "" }; + case "project-settings-opened": + return { open: false, query: "" }; + } +} + type SettledTimestampInput = Pick< SidebarThreadSummary, "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 728c122d9b..b36c311e26 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -66,6 +66,7 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -84,17 +85,14 @@ import { resolveShortcutCommand, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, - type ShortcutMatchContext, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { isPreviewFocused } from "../lib/previewFocus"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isModelPickerOpen } from "../modelPickerVisibility"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; @@ -134,6 +132,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, + filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -141,6 +140,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, planPinnedReorder, + reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarThreadStatus, @@ -153,6 +153,7 @@ import { sortThreadsForSidebar, useThreadJumpHintVisibility, } from "./Sidebar.logic"; +import { useSidebarActiveThreadScroll } from "./sidebar/useSidebarActiveThreadScroll"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, @@ -186,18 +187,19 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; +import { Menu, MenuCheckboxItem, MenuGroup, MenuPopup, MenuTrigger } from "./ui/menu"; import { - Menu, - MenuCheckboxItem, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuTrigger, -} from "./ui/menu"; + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, + useComboboxFilter, +} from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useSidebarActiveThreadScroll } from "./sidebar/useSidebarActiveThreadScroll"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -833,7 +835,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectTitle: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; - getCurrentShortcutContext: () => ShortcutMatchContext; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -1099,10 +1100,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { - const command = resolveShortcutCommand(event, props.keybindings, { - platform: navigator.platform, - context: props.getCurrentShortcutContext(), - }); + const command = resolveShortcutCommand(event, props.keybindings); if (command === "thread.rename") { event.preventDefault(); event.stopPropagation(); @@ -1114,14 +1112,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { event.preventDefault(); onThreadActivate(threadRef); }, - [ - onStartRename, - onThreadActivate, - props.getCurrentShortcutContext, - props.keybindings, - thread.title, - threadRef, - ], + [onStartRename, onThreadActivate, props.keybindings, thread.title, threadRef], ); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { @@ -1135,15 +1126,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [isRenaming, onStartRename, thread.title, threadRef], ); const renameCommittedRef = useRef(false); - const rowElementRef = useRef(null); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; }, [isRenaming]); - const handleRenameInputRef = useCallback((input: HTMLInputElement | null) => { - if (!input) return; - input.focus(); - input.select(); - }, []); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { event.stopPropagation(); @@ -1152,12 +1137,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { event.preventDefault(); renameCommittedRef.current = true; onCommitRename(threadRef, renamingTitle, thread.title); - window.requestAnimationFrame(() => rowElementRef.current?.focus()); } else if (event.key === "Escape") { event.preventDefault(); renameCommittedRef.current = true; onCancelRename(); - window.requestAnimationFrame(() => rowElementRef.current?.focus()); } }, [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], @@ -1257,7 +1240,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const title = isRenaming ? ( onRenameTitleChange(event.target.value)} @@ -1371,10 +1354,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { openCommandPalette({ open: "add-project" }), @@ -2036,8 +2014,6 @@ export default function Sidebar() { // the command was in flight, completing it must not yank them away. const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; - const routeThreadRefForShortcuts = useRef(routeThreadRef); - routeThreadRefForShortcuts.current = routeThreadRef; const environmentLabelById = useMemo( () => @@ -2137,6 +2113,51 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + // {value, label} items let Base UI drive the combobox selection contract + // while the popup search filters the same collection. + const projectScopeItems = useMemo( + () => [ + { value: "all", label: "All projects" }, + ...projectGroups.map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), + ], + [projectGroups], + ); + const projectGroupByScopeKey = useMemo( + () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), + [projectGroups], + ); + const selectedProjectScopeItem = useMemo( + () => + projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ?? + projectScopeItems[0]!, + [projectScopeItems, projectScopeKey], + ); + const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( + reduceSidebarProjectScopeMenuState, + { open: false, query: "" }, + ); + const projectScopeFilter = useComboboxFilter(); + // Filtering derives from the same React state that controls the input, so + // the visible query and the visible list can never desync — the peer wiring + // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope + // reset, not a searchable entry: it only shows while a project scope is + // active (there is something to reset) and the query is empty, so it can't + // outrank a project match under autoHighlight and no-hit queries reach the + // empty state. + const filteredProjectScopeItems = useMemo( + () => + filterSidebarProjectScopeItems({ + items: projectScopeItems, + activeScopeKey: projectScopeKey, + query: projectScopeMenuState.query, + matches: (item, query) => + projectScopeFilter.contains(item, query, (candidate) => candidate.label), + }), + [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -2199,7 +2220,7 @@ export default function Sidebar() { (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); - setProjectScopeMenuOpen(false); + dispatchProjectScopeMenu({ type: "project-settings-opened" }); if (isMobile) { setOpenMobile(false); } @@ -2230,13 +2251,14 @@ export default function Sidebar() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`) || - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey), - ); + const visible = threads.filter((thread) => { + if (thread.archivedAt !== null) return false; + if (scopedProjectKeys === null) return true; + return ( + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`) || + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey + ); + }); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -2521,6 +2543,7 @@ export default function Sidebar() { clearSelection(); } setSelectionAnchor(scopedThreadKey(threadRef)); + markSidebarThreadNavigation(scopedThreadKey(threadRef)); if (isMobile) { setOpenMobile(false); } @@ -2529,14 +2552,14 @@ export default function Sidebar() { params: buildThreadRouteParams(threadRef), }); }, - [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], - ); - const navigateToThreadFromSidebar = useCallback( - (threadRef: ScopedThreadRef) => { - markSidebarThreadNavigation(scopedThreadKey(threadRef)); - navigateToThread(threadRef); - }, - [markSidebarThreadNavigation, navigateToThread], + [ + clearSelection, + isMobile, + markSidebarThreadNavigation, + router, + setOpenMobile, + setSelectionAnchor, + ], ); const navigateToDraft = useCallback( @@ -2659,9 +2682,9 @@ export default function Sidebar() { if (isTrailingDoubleClick(event.detail)) { return; } - navigateToThreadFromSidebar(threadRef); + navigateToThread(threadRef); }, - [navigateToThreadFromSidebar, rangeSelectTo, toggleThreadSelection], + [navigateToThread, rangeSelectTo, toggleThreadSelection], ); // A settle per thread at a time: double clicks and repeated menu picks @@ -2864,7 +2887,7 @@ export default function Sidebar() { const attemptUnpin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { - const result = await unpinThread(threadRef); + const result = await confirmAndUnpinThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2877,7 +2900,7 @@ export default function Sidebar() { } })(); }, - [unpinThread], + [confirmAndUnpinThread], ); const handlePinnedDragEnd = useCallback( @@ -3482,30 +3505,21 @@ export default function Sidebar() { // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as // v1 — the keybinding layer is shared, only the ordered list differs. - const getCurrentSidebarShortcutContext = useCallback((): ShortcutMatchContext => { - const activeThreadRef = routeThreadRefForShortcuts.current; - return { - terminalFocus: isTerminalFocused(), - terminalOpen: activeThreadRef - ? selectThreadTerminalUiState( - useTerminalUiStateStore.getState().terminalUiStateByThreadKey, - activeThreadRef, - ).terminalOpen - : false, - previewFocus: isPreviewFocused(), - previewOpen: activeThreadRef - ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === - "preview" - : false, - modelPickerOpen: isModelPickerOpen(), - }; - }, []); + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, - context: getCurrentSidebarShortcutContext(), + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, }); const navigateToThreadKey = (targetThreadKey: string | null) => { if (!targetThreadKey) return false; @@ -3534,10 +3548,10 @@ export default function Sidebar() { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ - getCurrentSidebarShortcutContext, keybindings, navigateToThread, orderedThreadKeys, + routeTerminalOpen, routeThreadKey, threadByKey, ]); @@ -3553,8 +3567,9 @@ export default function Sidebar() { { platform: navigator.platform, context: { - ...getCurrentSidebarShortcutContext(), terminalFocus: terminalFocused, + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), }, }, ); @@ -3707,8 +3722,23 @@ export default function Sidebar() { {projectGroups.length > 0 || environmentVisibilityOptions.length > 1 ? (
{projectGroups.length > 0 ? ( - - item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + - - - - setProjectScopeKey(value === "all" ? null : (value as string)) - } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; + + +
+
+
+
+ No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; return ( - - - - {project.displayName} - - - + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + ); - })} -
-
-
+ }} + + + ) : ( )} @@ -3975,9 +4022,8 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } timestampFormat={timestampFormat} - getCurrentShortcutContext={getCurrentSidebarShortcutContext} onThreadClick={handleThreadClick} - onThreadActivate={navigateToThreadFromSidebar} + onThreadActivate={navigateToThread} onStartRename={startThreadRename} onRenameTitleChange={setRenamingTitle} onCommitRename={commitThreadRename} @@ -4167,11 +4213,7 @@ export default function Sidebar() {
{projects.length === 0 ? ( <> - - {allProjects.length === 0 - ? "No projects yet" - : "No projects in visible environments"} - + No projects yet
-
+
{keyedTaskSteps(steps).map(({ key, step }) => (
{ expect(markup).not.toContain('aria-label="Tool call failed"'); }); + it("keeps the collapsed summary icon neutral when the group ends in a failure", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ran 2 commands"); + expect(markup).toContain("lucide-terminal"); + expect(markup).not.toContain("lucide-x"); + expect(markup).not.toContain("text-destructive"); + // The failure stays discoverable for screen readers. + expect(markup).toContain("tool call failed"); + }); + it("keeps mixed work logs neutral after a later tool call succeeds", () => { const markup = renderToStaticMarkup( { expect(markup).not.toContain('data-testid="file-diff"'); }); - it("renders a failure marker for failed tool lifecycle entries", () => { + it("renders a muted failure marker for failed tool lifecycle entries", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("lucide-x"); expect(markup).toContain('aria-label="Tool call failed"'); + // Ordinary tool failures render muted, not red. + expect(markup).not.toContain("text-destructive"); + }); + + it("keeps the red treatment for severe orchestration failures", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("lucide-x"); + expect(markup).toContain("text-destructive"); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 37aee620bb..c496d9663b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -38,9 +38,10 @@ import { FileDiff } from "@pierre/diffs/react"; import { deriveTimelineEntries, workEntryDisplayIndicatesToolFailure, + workEntrySignalsSevereFailure, workLogEntryIsToolLike, } from "../../session-logic"; -import { type TurnDiffSummary } from "../../types"; +import { type ChatImageAttachment, isImageAttachment, type TurnDiffSummary } from "../../types"; import { getRenderablePatch, resolveDiffThemeName, @@ -1146,8 +1147,6 @@ function TimelineMinimap({ // TimelineRowContent — the actual row component // --------------------------------------------------------------------------- -type TimelineEntry = ReturnType[number]; -type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; @@ -1208,7 +1207,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const userImages = row.message.attachments ?? []; + const userImages = (row.message.attachments ?? []).filter(isImageAttachment); const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; @@ -1233,7 +1232,7 @@ function UserTimelineRow({ row }: { row: Extract {regularImages.length > 0 && (
- {regularImages.map((image: NonNullable[number]) => ( + {regularImages.map((image: ChatImageAttachment) => (
ctx.onToggleWorkGroup(row.groupId, row.id)} > - + @@ -1801,33 +1793,25 @@ function WorkGroupToggleTimelineRow({ : row.hiddenCount === 1 ? "log entry" : "log entries"; - const showHiddenFailure = row.hasFailure && !row.expanded; - return (
{props.showProvider && (
diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts new file mode 100644 index 0000000000..cbee3b6b16 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -0,0 +1,67 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; + +function entry(status: ServerProvider["status"]) { + return deriveProviderInstanceEntries([ + { + instanceId: ProviderInstanceId.make("opencode_work"), + driver: ProviderDriverKind.make("opencode"), + enabled: true, + installed: true, + version: null, + status, + auth: { status: "authenticated" }, + checkedAt: "2026-08-28T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ])[0]!; +} + +describe("shouldIncludeModelPickerOption", () => { + it.each(["error", "warning"] as const)( + "keeps only the active synthetic OpenCode row when the provider status is %s", + (status) => { + const providerEntry = entry(status); + const activeInstanceId = ProviderInstanceId.make("opencode_work"); + const activeModel = "openrouter/kimi-k3"; + + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: activeModel, + name: activeModel, + isUnavailable: true, + }, + activeInstanceId, + activeModel, + }), + ).toBe(true); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { slug: "stale/model", name: "Stale model" }, + activeInstanceId, + activeModel, + }), + ).toBe(false); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: "other/missing", + name: "Other missing", + isUnavailable: true, + }, + activeInstanceId, + activeModel, + }), + ).toBe(false); + }, + ); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8729b1bf8f..a8ebac4f4e 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -53,8 +53,25 @@ type ModelPickerItem = { instanceAccentColor?: string | undefined; continuationGroupKey?: string | undefined; isLegacy?: boolean | undefined; + isUnavailable?: boolean | undefined; }; +export function shouldIncludeModelPickerOption(input: { + readonly entry: ProviderInstanceEntry; + readonly option: ModelEsque; + readonly activeInstanceId: ProviderInstanceId; + readonly activeModel: string; +}): boolean { + if (isProviderInstancePickerReady(input.entry)) return true; + return ( + input.entry.enabled && + input.entry.driverKind === "opencode" && + input.entry.instanceId === input.activeInstanceId && + input.option.slug === input.activeModel && + input.option.isUnavailable === true + ); +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -107,9 +124,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const modelListRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useClientSettings((s) => s.favorites ?? []); + const activeEntry = props.instanceEntries.find( + (entry) => entry.instanceId === props.activeInstanceId, + ); + const activeInstanceHasSelectableUnavailableModel = + activeEntry !== undefined && + (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => + shouldIncludeModelPickerOption({ + entry: activeEntry, + option, + activeInstanceId: props.activeInstanceId, + activeModel: props.model, + }), + ) && + !isProviderInstancePickerReady(activeEntry); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { - if (props.lockedProvider !== null) { + if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) { // When locked, prime the sidebar to the currently-active instance // so jumping into the picker keeps the focused instance visible. return props.activeInstanceId; @@ -189,15 +220,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [props.lockedContinuationGroupKey, props.lockedProvider], ); - const readyInstanceSet = useMemo(() => { - const ready = new Set(); - for (const entry of instanceEntries) { - if (isProviderInstancePickerReady(entry)) { - ready.add(entry.instanceId); - } + const selectableUnavailableInstanceIds = useMemo(() => { + if (!activeInstanceHasSelectableUnavailableModel) { + return undefined; } - return ready; - }, [instanceEntries]); + return new Set([props.activeInstanceId]); + }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -212,16 +240,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // its models — stale options shouldn't appear in the picker. continue; } - if (!readyInstanceSet.has(instanceId)) { - continue; - } for (const model of models) { + if ( + !shouldIncludeModelPickerOption({ + entry, + option: model, + activeInstanceId: props.activeInstanceId, + activeModel: props.model, + }) + ) { + continue; + } out.push({ slug: model.slug, name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), ...(model.isLegacy ? { isLegacy: true } : {}), + ...(model.isUnavailable ? { isUnavailable: true } : {}), instanceId, driverKind: entry.driverKind, instanceDisplayName: entry.displayName, @@ -233,7 +269,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); + }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; @@ -609,6 +645,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onSelectInstance={handleSelectInstance} instanceEntries={sidebarInstanceEntries} showFavorites + {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} {...(lockedDisabledInstanceIds ? { disabledInstanceIds: lockedDisabledInstanceIds, @@ -768,6 +805,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { preferShortName={!isLocked} useTriggerLabel={false} showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} + unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 98db6a9c18..dd53270268 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -54,6 +54,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { showFavorites?: boolean; /** Instance ids shown in the rail but unavailable for the current picker context. */ disabledInstanceIds?: ReadonlySet; + /** Non-ready instances whose selected unavailable model remains reachable. */ + selectableUnavailableInstanceIds?: ReadonlySet; getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; /** * Instance id values that should render the "new" sparkle badge. Callers @@ -135,7 +137,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { {props.instanceEntries.map((entry) => { const isUnavailable = !isProviderInstancePickerReady(entry); const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; - const isDisabled = isUnavailable || isContextDisabled; + const unavailableSelectionIsReachable = + props.selectableUnavailableInstanceIds?.has(entry.instanceId) ?? false; + const isDisabled = + (isUnavailable && !unavailableSelectionIsReachable) || isContextDisabled; const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; @@ -168,7 +173,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { disabled={isDisabled} type="button" aria-label={ - isDisabled + isUnavailable || isContextDisabled ? tooltip : showNewBadge ? `${entry.displayName}, new` diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx new file mode 100644 index 0000000000..5d5bf94ce6 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -0,0 +1,102 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; +import type { ModelEsque } from "./providerIconUtils"; + +function providerEntry(instanceId: string, driver: string) { + const provider: ServerProvider = { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-28T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + return deriveProviderInstanceEntries([provider])[0]!; +} + +function renderPicker(input: { + instanceId: string; + driver: string; + model: string; + options: ReadonlyArray; + includeEntry?: boolean; +}) { + const instanceId = ProviderInstanceId.make(input.instanceId); + const entry = providerEntry(input.instanceId, input.driver); + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("ProviderModelPicker", () => { + it("shows a missing model slug for a custom OpenCode instance", () => { + const markup = renderPicker({ + instanceId: "team_runtime", + driver: "opencode", + model: "openrouter/missing-model", + options: [{ slug: "openrouter/fallback", name: "Fallback model" }], + }); + + expect(markup).toContain("openrouter/missing-model"); + expect(markup).not.toContain("Fallback model"); + }); + + it.each(["codex", "claudeAgent", "cursor", "grok"])( + "uses the first option label for a missing %s model", + (driver) => { + const markup = renderPicker({ + instanceId: `${driver}_work`, + driver, + model: "missing-model", + options: [{ slug: "fallback-model", name: "Fallback model" }], + }); + + expect(markup).toContain("Fallback model"); + expect(markup).not.toContain(">missing-model<"); + }, + ); + + it("prefers a matching model for OpenCode", () => { + const markup = renderPicker({ + instanceId: "custom_runtime", + driver: "opencode", + model: "openrouter/selected", + options: [ + { slug: "openrouter/fallback", name: "Fallback model" }, + { slug: "openrouter/selected", name: "Selected model" }, + ], + }); + + expect(markup).toContain("Selected model"); + expect(markup).not.toContain("Fallback model"); + }); + + it("uses the first option when the active instance entry is missing", () => { + const markup = renderPicker({ + instanceId: "missing_instance", + driver: "opencode", + model: "missing-model", + options: [{ slug: "fallback-model", name: "Fallback model" }], + includeEntry: false, + }); + + expect(markup).toContain("Fallback model"); + expect(markup).not.toContain(">missing-model<"); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5566160bcf..db55edcf88 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import { memo, useEffect, useMemo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; +import { Badge } from "../ui/badge"; import { buttonVariants } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -58,15 +59,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; - // If the current slug belongs to a different instance (for example after - // a provider switch or disable), prefer the active instance's first - // option so the trigger icon and label stay in sync instead of showing - // a stale foreign slug. + // OpenCode can keep a model through a transient catalog refresh. Other + // providers keep the active instance's first option as their normal fallback. const selectedModel = selectedInstanceOptions.find((option) => option.slug === props.model) ?? - selectedInstanceOptions[0]; + (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; - const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + const triggerLabel = selectedModel + ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + : props.model; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -179,6 +180,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {triggerLabel} + {selectedModel?.isUnavailable ? ( + + Unavailable + + ) : null}