diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 94d4af17e41a..7987ae463352 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -1,9 +1,7 @@ name: Deploy T3 Connect relay on: - push: - branches: - - main + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 668d1fcb59d7..fd7c56509665 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: diff --git a/README.md b/README.md index 0fbbe90ee660..dedeebc59696 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon). +T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, Grok, Muse Code, and OpenCode, with more coming soon). ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, and OpenCode. +> T3 Code currently supports Codex, Claude, Cursor, Grok, Muse Code, and OpenCode. > Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login` +> - Muse Code: install [Muse Code](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2) and run `muse login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` ### Run without installing @@ -57,7 +58,7 @@ There's no public docs site yet, checkout the miscellaneous markdown files in [d - [Remote access](./docs/user/remote-access.md) - [Keeping T3 Code in sync](./docs/user/server-updates.md) - [Architecture overview](./docs/architecture/overview.md) -- [Provider guides](./docs/providers/codex.md) +- Provider guides: [Codex](./docs/providers/codex.md), [Claude](./docs/providers/claude.md), and [Muse Code](./docs/providers/muse.md) - [Operations](./docs/operations/ci.md) - [Reference](./docs/reference/encyclopedia.md) diff --git a/apps/server/src/authConnector/AuthConnectorManager.test.ts b/apps/server/src/authConnector/AuthConnectorManager.test.ts index aa44ab0e7e9e..7b245ee8cb5b 100644 --- a/apps/server/src/authConnector/AuthConnectorManager.test.ts +++ b/apps/server/src/authConnector/AuthConnectorManager.test.ts @@ -1,8 +1,36 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { testHelpers } from "./AuthConnectorManager.ts"; +import { ServerConfig } from "../config.ts"; +import { start, testHelpers } from "./AuthConnectorManager.ts"; describe("AuthConnectorManager output parsing", () => { + it.effect("rejects Muse authentication before spawning a process when Muse is withheld", () => + Effect.gen(function* () { + const defaultConfig = yield* ServerConfig; + const error = yield* start({ connector: "muse", method: "account" }).pipe( + Effect.provideService(ServerConfig, { + ...defaultConfig, + museCodeEnabled: false, + }), + Effect.flip, + ); + + expect(error).toMatchObject({ + operation: "start", + detail: "Muse Code is not available in this T3 Code environment.", + }); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-auth-connector-gate-test-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + it("extracts GitHub device authorization details", () => { const output = [ "! First copy your one-time code: ABCD-1234", @@ -35,6 +63,34 @@ describe("AuthConnectorManager output parsing", () => { ); }); + it("extracts Muse's Meta device authorization details", () => { + const output = [ + "Open this page to sign in:", + " https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ", + "confirm this code matches:", + " ZKWQ-XCBZ", + "Waiting for approval…", + ].join("\n"); + + expect(testHelpers.extractUserCode(output)).toBe("ZKWQ-XCBZ"); + expect(testHelpers.extractUrl(output)).toBe( + "https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ", + ); + expect( + testHelpers.parseOutputForTest({ + connector: "muse", + method: "account", + flow: "device", + output, + }).snapshot, + ).toMatchObject({ + status: "waiting", + stage: "authorize", + verificationUrl: "https://auth.meta.com/oauth/device/?code=ZKWQ-XCBZ", + userCode: "ZKWQ-XCBZ", + }); + }); + it("keeps Claude authorization query parameters intact", () => { const output = "If the browser did not open, visit: https://claude.com/cai/oauth/authorize?code=true&state=opaque"; @@ -44,6 +100,13 @@ describe("AuthConnectorManager output parsing", () => { ); }); + it("rejects lookalike authentication hosts", () => { + expect( + testHelpers.extractUrl("Open https://auth.meta.com.evil.example/oauth/device to continue"), + ).toBeNull(); + expect(testHelpers.extractUrl("Open http://auth.meta.com/oauth/device to continue")).toBeNull(); + }); + it("extracts Microsoft device authorization details for Azure DevOps", () => { const output = "To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code A1B2C3D4 to authenticate."; @@ -89,6 +152,37 @@ describe("AuthConnectorManager output parsing", () => { expect(spec?.ptyName).toBe("dumb"); }); + it("starts Muse account login with its device flow", () => { + expect( + testHelpers.launchSpec({ + connector: "muse", + method: "account", + }), + ).toMatchObject({ + command: "muse", + args: ["login"], + flow: "device", + }); + }); + + it("starts Muse API-key login through stdin", () => { + expect( + testHelpers.launchSpec({ + connector: "muse", + method: "api-key", + }), + ).toMatchObject({ + command: "muse", + args: ["auth", "set", "--provider", "meta", "--api-key-stdin"], + flow: "secret", + fields: [{ key: "secret", type: "password" }], + }); + expect(testHelpers.secretInputTerminator({ connector: "muse", method: "api-key" })).toBe( + "\r\u0004", + ); + expect(testHelpers.secretInputTerminator({ connector: "codex", method: "api-key" })).toBe("\r"); + }); + it("accepts Claude's full callback URL or short authorization code", () => { expect(testHelpers.claudeCallbackField()).toMatchObject({ key: "callback", diff --git a/apps/server/src/authConnector/AuthConnectorManager.ts b/apps/server/src/authConnector/AuthConnectorManager.ts index eea357946d8c..588a2b354e7e 100644 --- a/apps/server/src/authConnector/AuthConnectorManager.ts +++ b/apps/server/src/authConnector/AuthConnectorManager.ts @@ -20,6 +20,7 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { ServerConfig } from "../config.ts"; import { writeStoredBitbucketCredentials } from "../sourceControl/BitbucketCredentialStore.ts"; const SESSION_TTL_MS = 15 * 60 * 1_000; @@ -88,14 +89,38 @@ function stripAnsi(input: string): string { return input.replace(ANSI_PATTERN, "").replace(/\r/g, ""); } +const AUTH_URL_HOSTS = [ + "github.com", + "gitlab.com", + "openai.com", + "claude.com", + "cursor.com", + "x.ai", + "auth.meta.com", + "microsoft.com", + "microsoftonline.com", + "aka.ms", +] as const; + +function isAllowedAuthUrl(candidate: string): boolean { + try { + const parsed = new URL(candidate); + const hostname = parsed.hostname.toLowerCase(); + return ( + parsed.protocol === "https:" && + AUTH_URL_HOSTS.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)) + ); + } catch { + return false; + } +} + function extractUrl(output: string): string | null { const matches = output.match(/https?:\/\/[^\s<>"']+/giu) ?? []; - const candidate = matches.findLast((url) => - /(?:github\.com|gitlab\.com|openai\.com|claude\.com|cursor\.com|x\.ai|microsoft\.com|microsoftonline\.com|aka\.ms)/iu.test( - url, - ), + return ( + matches.map((candidate) => candidate.replace(/[),.;]+$/u, "")).findLast(isAllowedAuthUrl) ?? + null ); - return candidate?.replace(/[),.;]+$/u, "") ?? null; } function extractUserCode(output: string): string | null { @@ -105,6 +130,7 @@ function extractUserCode(output: string): string | null { /enter code:\s*([A-Z0-9-]{6,})/iu, /enter the code\s+([A-Z0-9-]{6,})/iu, /confirm this code(?: in your browser)?:\s*([A-Z0-9-]{6,})/iu, + /confirm this code matches:\s*([A-Z0-9-]{6,})/iu, /user_code=([A-Z0-9-]{6,})/iu, ]; for (const pattern of patterns) { @@ -269,6 +295,10 @@ function secretFields(input: AuthConnectorStartInput): ReadonlyArray): string { + return input.connector === "muse" && input.method === "api-key" ? "\r\u0004" : "\r"; +} + type LaunchSpec = { readonly command: string; readonly args: ReadonlyArray; @@ -322,6 +352,22 @@ function launchSpec(input: AuthConnectorStartInput): LaunchSpec | null { flow: "device", message: "Starting xAI sign-in…", }; + case "muse": + if (input.method !== "account" && input.method !== "api-key") return null; + return input.method === "api-key" + ? { + command: "muse", + args: ["auth", "set", "--provider", "meta", "--api-key-stdin"], + flow: "secret", + message: "Enter a Meta API key.", + fields: secretFields(input), + } + : { + command: "muse", + args: ["login"], + flow: "device", + message: "Starting secure Meta sign-in…", + }; case "github": if (input.method !== "account" && input.method !== "token") return null; return input.method === "token" @@ -535,7 +581,14 @@ async function submitBitbucket( export const start = Effect.fn("AuthConnectorManager.start")(function* ( input: AuthConnectorStartInput, -): Effect.fn.Return { +): Effect.fn.Return { + const { museCodeEnabled } = yield* ServerConfig; + if (input.connector === "muse" && !museCodeEnabled) { + return yield* connectorError( + "start", + "Muse Code is not available in this T3 Code environment.", + ); + } if (input.connector === "bitbucket" && input.method !== "token") { return yield* connectorError("start", "That sign-in method is not supported."); } @@ -635,7 +688,7 @@ export const submit = Effect.fn("AuthConnectorManager.submit")(function* ( return yield* connectorError("submit", "Enter the requested credential to continue."); } clearSensitiveOutput(session); - session.process?.write(`${secret}\r`); + session.process?.write(`${secret}${secretInputTerminator(session.snapshot)}`); setSnapshot(session, { status: "starting", stage: "verifying", @@ -711,6 +764,7 @@ export const testHelpers = { hasGitHubCredentialPrompt, hasGitHubBrowserPrompt, claudeCallbackField, + secretInputTerminator, launchSpec, parseOutputForTest, }; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e1ed7b7f9afe..cd812fea2f85 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -92,6 +92,7 @@ const makeCliTestServerConfig = (baseDir: string) => tailscaleServeEnabled: false, tailscaleServePort: 443, managedDevPc: false, + museCodeEnabled: true, } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index b426a68ae212..8b8359db26b9 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -38,7 +38,7 @@ const makeDesktopBootstrap = ( }); it.layer(NodeServices.layer)("cli config resolution", (it) => { - const defaultObservabilityConfig = { + const defaultRuntimeConfig = { traceMinLevel: "Info", traceTimingEnabled: true, traceBatchWindowMs: 200, @@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", + museCodeEnabled: true, } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { @@ -59,6 +60,76 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { return fd; }); + const noServerFlags = { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: Option.none(), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + } satisfies Parameters[0]; + + const resolveWithEnv = (env: Readonly>) => + resolveServerConfig(noServerFlags, Option.none()).pipe( + Effect.provide( + Layer.mergeAll(ConfigProvider.layer(ConfigProvider.fromEnv({ env })), NetService.layer), + ), + ); + + it.effect("enables Muse by default for a standalone server", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-muse-standalone-" }); + const resolved = yield* resolveWithEnv({ + T3CODE_HOME: baseDir, + T3CODE_MODE: "desktop", + T3CODE_PORT: "4101", + }); + + expect(resolved.managedDevPc).toBe(false); + expect(resolved.museCodeEnabled).toBe(true); + }), + ); + + it.effect("withholds Muse by default for a managed server", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-muse-managed-" }); + const resolved = yield* resolveWithEnv({ + T3CODE_HOME: baseDir, + T3CODE_MANAGED_DEVPC: "true", + T3CODE_MODE: "desktop", + T3CODE_PORT: "4102", + }); + + expect(resolved.managedDevPc).toBe(true); + expect(resolved.museCodeEnabled).toBe(false); + }), + ); + + it.effect("allows a managed server to explicitly enable Muse", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-muse-managed-enabled-" }); + const resolved = yield* resolveWithEnv({ + T3CODE_HOME: baseDir, + T3CODE_MANAGED_DEVPC: "true", + T3CODE_MODE: "desktop", + T3CODE_MUSE_ENABLED: "true", + T3CODE_PORT: "4103", + }); + + expect(resolved.managedDevPc).toBe(true); + expect(resolved.museCodeEnabled).toBe(true); + }), + ); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -98,6 +169,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", + T3CODE_MUSE_ENABLED: "false", }, }), ), @@ -108,7 +180,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Warn", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, mode: "desktop", port: 4001, cwd: process.cwd(), @@ -119,6 +191,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, managedDevPc: false, + museCodeEnabled: false, startupPresentation: "browser", desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, @@ -179,7 +252,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -253,7 +326,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -326,7 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", @@ -457,7 +530,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -525,7 +598,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); expect(resolved).toEqual({ logLevel: "Info", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", @@ -591,7 +664,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", - ...defaultObservabilityConfig, + ...defaultRuntimeConfig, mode: "web", port: 3773, cwd: process.cwd(), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6fff4aa10a73..81e07f8ede96 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -111,6 +111,10 @@ const EnvServerConfig = Config.all({ Config.map(Option.getOrUndefined), ), managedDevPc: Config.boolean("T3CODE_MANAGED_DEVPC").pipe(Config.withDefault(false)), + museCodeEnabled: Config.boolean("T3CODE_MUSE_ENABLED").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), managedGatewayToken: Config.string("WORKSPACE_GATEWAY_TOKEN").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -370,6 +374,7 @@ export const resolveServerConfig = ( devUrl, noBrowser, managedDevPc: env.managedDevPc, + museCodeEnabled: env.museCodeEnabled ?? !env.managedDevPc, managedGatewayToken: env.managedGatewayToken, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 15d1201fe8b1..7675c76f7cc6 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -74,6 +74,13 @@ export class ServerConfig extends Context.Service< readonly devUrl: URL | undefined; readonly noBrowser: boolean; readonly managedDevPc: boolean; + /** + * Whether the Muse Code driver and its authentication connector are + * available in this server process. Standalone T3 Code enables the driver + * by default; managed deployments can explicitly withhold it while still + * running the same release artifact. + */ + readonly museCodeEnabled: boolean; /** * Shared only with Aldo's loopback workspace gateway. It authenticates * managed automation routes that are never exposed by standalone T3 Code. @@ -195,6 +202,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( devUrl, noBrowser: false, managedDevPc: false, + museCodeEnabled: true, startupPresentation: "browser", }); }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9aeebe50ebfa..63bf1e8b86ed 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -45,6 +45,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { devUrl: undefined, noBrowser: false, managedDevPc: false, + museCodeEnabled: true, startupPresentation: "browser", } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/provider/Drivers/MuseDriver.test.ts b/apps/server/src/provider/Drivers/MuseDriver.test.ts new file mode 100644 index 000000000000..69a3c6aa52c1 --- /dev/null +++ b/apps/server/src/provider/Drivers/MuseDriver.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { BUILT_IN_DRIVERS, resolveBuiltInDrivers } from "../builtInDrivers.ts"; +import { MuseDriver } from "./MuseDriver.ts"; + +it("registers Muse Code as a built-in multi-instance driver", () => { + expect(MuseDriver.driverKind).toBe(ProviderDriverKind.make("muse")); + expect(MuseDriver.metadata).toEqual({ + displayName: "Muse Code", + supportsMultipleInstances: true, + }); + expect(MuseDriver.defaultConfig()).toEqual({ + enabled: true, + binaryPath: "muse", + launchArgs: "", + customModels: [], + }); + expect(BUILT_IN_DRIVERS).toContain(MuseDriver); +}); + +it("omits Muse Code from the executable driver set when its runtime gate is disabled", () => { + expect(resolveBuiltInDrivers({ museCodeEnabled: true })).toContain(MuseDriver); + expect(resolveBuiltInDrivers({ museCodeEnabled: false })).not.toContain(MuseDriver); +}); diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/apps/server/src/provider/Drivers/MuseDriver.ts new file mode 100644 index 000000000000..8a50ffc540ee --- /dev/null +++ b/apps/server/src/provider/Drivers/MuseDriver.ts @@ -0,0 +1,166 @@ +import { MuseSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeMuseAdapter } from "../Layers/MuseAdapter.ts"; +import { + buildInitialMuseProviderSnapshot, + checkMuseProviderStatus, + makeMuseEnvironment, +} from "../Layers/MuseProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + createProviderVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeMuseSettings = Schema.decodeSync(MuseSettings); +const DRIVER_KIND = ProviderDriverKind.make("muse"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type MuseDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const MuseDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Muse Code", + supportsMultipleInstances: true, + }, + configSchema: MuseSettings, + defaultConfig: (): MuseSettings => decodeMuseSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = makeMuseEnvironment(mergeProviderInstanceEnvironment(environment)); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies MuseSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeMuseAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkMuseProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialMuseProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot: currentSnapshot, publishSnapshot }) => + publishSnapshot({ + ...currentSnapshot, + versionAdvisory: createProviderVersionAdvisory({ + driver: DRIVER_KIND, + currentVersion: currentSnapshot.version, + checkedAt: currentSnapshot.checkedAt, + maintenanceCapabilities, + }), + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Muse Code snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4ae654a51873..ff188a7e9607 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -213,6 +213,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + upsertIfActive: () => Effect.succeed(true), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/MuseAdapter.test.ts b/apps/server/src/provider/Layers/MuseAdapter.test.ts new file mode 100644 index 000000000000..7309976e02be --- /dev/null +++ b/apps/server/src/provider/Layers/MuseAdapter.test.ts @@ -0,0 +1,1076 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; +import { describe, expect, it } from "@effect/vitest"; +import { MuseSettings, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import type { MuseAdapterShape } from "../Services/MuseAdapter.ts"; +import { buildMuseExecArgs, makeMuseAdapter } from "./MuseAdapter.ts"; + +const MUSE = ProviderDriverKind.make("muse"); +const MUSE_INSTANCE = ProviderInstanceId.make("muse"); +const MUSE_MODEL = "muse-spark-1.2"; +const decodeMuseSettings = Schema.decodeSync(MuseSettings); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +type CapturedCommand = { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly cwd?: string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + readonly shell?: boolean | string | undefined; + readonly stdin?: string | undefined; + }; +}; + +function captureStandardCommand(command: ChildProcess.Command): CapturedCommand { + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected Muse to spawn a standard child process."); + } + return command as unknown as CapturedCommand; +} + +function makeHandle(input?: { + readonly stdout?: string | undefined; + readonly stderr?: string | undefined; + readonly exitCode?: Effect.Effect | undefined; + readonly onKill?: (() => Effect.Effect) | undefined; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(501), + exitCode: input?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => input?.onKill?.() ?? Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(input?.stdout ?? "")), + stderr: Stream.encodeText(Stream.make(input?.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawner( + spawn: (command: CapturedCommand) => Effect.Effect, +) { + return ChildProcessSpawner.make((command) => spawn(captureStandardCommand(command))); +} + +function provideMuseTestServices( + effect: Effect.Effect, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +) { + const platformServices = Layer.mergeAll( + NodeCrypto.layer, + NodeFileSystem.layer, + NodePath.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const testServices = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-muse-adapter-test-", + }).pipe(Layer.provideMerge(platformServices)); + return effect.pipe(Effect.provide(testServices)); +} + +function museEvent( + payloadType: string, + payload: Record, + sequence: number, +): string { + return encodeUnknownJson({ + schema_version: 1, + id: `event-${sequence}`, + stream: { kind: "session", id: "muse-session-test" }, + sequence, + recorded_at: 1_780_531_400_000_000 + sequence, + record_type: "status", + durability: "ephemeral", + payload_type: payloadType, + payload_schema_version: 1, + payload, + }); +} + +function completedOutput(text: string): string { + return [ + museEvent("run.output.delta", { kind: "run_output_delta", text }, 1), + museEvent( + "run.terminal.completed", + { kind: "run_terminal", terminal: "completed", text, reason: null }, + 2, + ), + "", + ].join("\n"); +} + +const startMuseSession = ( + adapter: MuseAdapterShape, + threadId: ThreadId, + input?: { + readonly runtimeMode?: "approval-required" | "auto" | "full-access"; + readonly approvalPolicy?: "on-request" | "never"; + readonly sandboxMode?: "read-only" | "workspace-write" | "danger-full-access"; + readonly resumeCursor?: unknown; + }, +) => + adapter.startSession({ + threadId, + provider: MUSE, + providerInstanceId: MUSE_INSTANCE, + cwd: process.cwd(), + runtimeMode: input?.runtimeMode ?? "auto", + approvalPolicy: input?.approvalPolicy, + sandboxMode: input?.sandboxMode, + resumeCursor: input?.resumeCursor, + modelSelection: { instanceId: MUSE_INSTANCE, model: MUSE_MODEL }, + }); + +describe("buildMuseExecArgs", () => { + const base = { + sessionId: "muse-session-1", + cwd: "/workspace/project", + promptFile: "/tmp/prompt.md", + model: MUSE_MODEL, + reasoningEffort: "high", + imagePaths: [] as ReadonlyArray, + }; + + it("maps managed runtime modes to exact Muse safety arguments", () => { + expect( + buildMuseExecArgs({ + ...base, + runtimeMode: "approval-required", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }), + ).toEqual([ + "exec", + "--json", + "--provider", + "meta", + "--session-id", + "muse-session-1", + "--workspace", + "/workspace/project", + "--prompt-file", + "/tmp/prompt.md", + "--model", + MUSE_MODEL, + "--reasoning-effort", + "high", + "--parallel-tool-calls", + "--trust-workspace", + "--disable-approval", + "--disable-write", + "--disable-shell", + ]); + + expect( + buildMuseExecArgs({ + ...base, + runtimeMode: "full-access", + }).slice(-1), + ).toEqual(["--yolo"]); + + expect( + buildMuseExecArgs({ + ...base, + runtimeMode: "auto", + sandboxMode: "danger-full-access", + }).slice(-2), + ).toEqual(["--trust-workspace", "--disable-approval"]); + }); + + it("makes runtime mode authoritative over contradictory low-level policies", () => { + const approvalRequiredArgs = buildMuseExecArgs({ + ...base, + runtimeMode: "approval-required", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }); + expect(approvalRequiredArgs).toContain("--disable-write"); + expect(approvalRequiredArgs).toContain("--disable-shell"); + expect(approvalRequiredArgs).not.toContain("--disable-sandbox"); + expect(approvalRequiredArgs).not.toContain("--yolo"); + + const automaticArgs = buildMuseExecArgs({ + ...base, + runtimeMode: "auto", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }); + expect(automaticArgs).toContain("--disable-approval"); + expect(automaticArgs).not.toContain("--disable-sandbox"); + expect(automaticArgs).not.toContain("--yolo"); + }); + + it("keeps plan mode read-only even when the session and launch args allow full access", () => { + const args = buildMuseExecArgs({ + ...base, + runtimeMode: "full-access", + interactionMode: "plan", + sandboxMode: "danger-full-access", + launchArgs: "--yolo --disable-sandbox --max-model-steps=12", + }); + + expect(args).toContain("--disable-write"); + expect(args).toContain("--disable-shell"); + expect(args).toEqual(expect.arrayContaining(["--max-model-steps", "12"])); + expect(args).not.toContain("--yolo"); + expect(args).not.toContain("--disable-sandbox"); + }); + + it("allowlists tuning args and drops managed, stateful, and unknown launch args", () => { + const args = buildMuseExecArgs({ + ...base, + runtimeMode: "approval-required", + launchArgs: [ + "--max-model-steps 8", + "--max-tool-output-bytes=4096", + "--context-compaction-soft-threshold .7", + "--disable-web-tools", + "--yolo", + "--disable-sandbox", + "--sandbox-network enabled", + "--worktree create", + "--base-url https://example.invalid", + "--workspace /", + "--prompt-file /tmp/unmanaged-prompt", + "--future-unsafe-option yes", + ].join(" "), + }); + + expect(args).toEqual( + expect.arrayContaining([ + "--max-model-steps", + "8", + "--max-tool-output-bytes", + "4096", + "--context-compaction-soft-threshold", + ".7", + "--disable-web-tools", + ]), + ); + expect(args).not.toContain("--yolo"); + expect(args).not.toContain("--disable-sandbox"); + expect(args).not.toContain("--sandbox-network"); + expect(args).not.toContain("--worktree"); + expect(args).not.toContain("--base-url"); + expect(args).not.toContain("--future-unsafe-option"); + expect(args.filter((arg) => arg === "--workspace")).toHaveLength(1); + expect(args[args.indexOf("--workspace") + 1]).toBe(base.cwd); + expect(args.filter((arg) => arg === "--prompt-file")).toHaveLength(1); + expect(args[args.indexOf("--prompt-file") + 1]).toBe(base.promptFile); + }); +}); + +describe("MuseAdapter", () => { + it.effect("starts and resumes a stable Muse session id", () => + Effect.scoped( + provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-session-id-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + const session = yield* startMuseSession(adapter, threadId, { + resumeCursor: { schemaVersion: 1, sessionId: "resume-session-42" }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + + expect(session.provider).toBe("muse"); + expect(session.providerInstanceId).toBe("muse"); + expect(session.resumeCursor).toEqual({ + schemaVersion: 1, + sessionId: "resume-session-42", + }); + expect(events.map((event) => event.type)).toEqual([ + "session.started", + "session.state.changed", + "thread.started", + ]); + expect(events[2]).toMatchObject({ + type: "thread.started", + payload: { providerThreadId: "resume-session-42" }, + }); + }), + mockSpawner(() => Effect.die("spawn should not run while starting a session")), + ), + ), + ); + + it.effect("spawns managed Muse exec args and streams JSONL as canonical events", () => + Effect.scoped( + Effect.gen(function* () { + let spawned: CapturedCommand | undefined; + let prompt = ""; + const stdout = [ + museEvent("run.reasoning.delta", { text: "considering" }, 1), + museEvent( + "task.lifecycle.proposed", + { + task_id: "shell-1", + event: { kind: "proposed", task_id: "shell-1", task_kind: "tool.bash" }, + }, + 2, + ), + museEvent( + "task.lifecycle.side_effect_intent", + { + task_id: "shell-1", + event: { kind: "side_effect_intent", task_id: "shell-1", operation: "tool.bash" }, + }, + 3, + ), + museEvent( + "task.lifecycle.started", + { task_id: "shell-1", event: { kind: "started", task_id: "shell-1" } }, + 4, + ), + museEvent( + "task.lifecycle.output", + { + task_id: "shell-1", + event: { + kind: "output", + task_id: "shell-1", + chunk: encodeUnknownJson({ output: "tests passed" }), + }, + }, + 5, + ), + museEvent( + "task.lifecycle.completed", + { task_id: "shell-1", event: { kind: "completed", task_id: "shell-1" } }, + 6, + ), + museEvent( + "task.lifecycle.proposed", + { + task_id: "shell-timeout", + event: { kind: "proposed", task_id: "shell-timeout", task_kind: "tool.bash" }, + }, + 7, + ), + museEvent( + "task.lifecycle.timed_out", + { task_id: "shell-timeout", event: { kind: "timed_out", task_id: "shell-timeout" } }, + 8, + ), + museEvent("run.output.delta", { text: "Implemented safely." }, 9), + museEvent( + "run.terminal.completed", + { terminal: "completed", text: "Implemented safely.", reason: null }, + 10, + ), + "", + ].join("\n"); + const spawner = mockSpawner((command) => + Effect.sync(() => { + spawned = command; + const promptIndex = command.args.indexOf("--prompt-file"); + prompt = NodeFS.readFileSync(command.args[promptIndex + 1]!, "utf8"); + return makeHandle({ stdout }); + }), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + const threadId = ThreadId.make("muse-streaming-test"); + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ + binaryPath: process.execPath, + launchArgs: "--disable-web-tools --max-model-steps 12", + }), + { environment: { META_API_KEY: "test-secret" } }, + ); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId, { + runtimeMode: "approval-required", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + resumeCursor: { schemaVersion: 1, sessionId: "managed-session-7" }, + }); + + const imageId = "image-1"; + const fileId = "file-1"; + const result = yield* adapter.sendTurn({ + threadId, + input: "Inspect these attachments.", + attachments: [ + { + type: "image", + id: imageId, + name: "screen.png", + mimeType: "image/png", + sizeBytes: 10, + }, + { + type: "file", + id: fileId, + name: "notes.md", + mimeType: "text/markdown", + sizeBytes: 20, + }, + ], + modelSelection: { + instanceId: MUSE_INSTANCE, + model: MUSE_MODEL, + options: [{ id: "reasoningEffort", value: "ultra" }], + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const imagePath = path.resolve(config.attachmentsDir, `${imageId}.png`); + const filePath = path.resolve(config.attachmentsDir, `${fileId}.md`); + const promptFile = spawned?.args[spawned.args.indexOf("--prompt-file") + 1]; + + expect(result.resumeCursor).toEqual({ + schemaVersion: 1, + sessionId: "managed-session-7", + }); + expect(spawned?.command).toBe(process.execPath); + expect(spawned?.args).toEqual([ + "exec", + "--disable-web-tools", + "--max-model-steps", + "12", + "--json", + "--provider", + "meta", + "--session-id", + "managed-session-7", + "--workspace", + process.cwd(), + "--prompt-file", + promptFile, + "--model", + MUSE_MODEL, + "--reasoning-effort", + "ultra", + "--parallel-tool-calls", + "--trust-workspace", + "--disable-approval", + "--disable-write", + "--disable-shell", + "--image", + imagePath, + ]); + expect(spawned?.options).toMatchObject({ + cwd: process.cwd(), + shell: false, + stdin: "ignore", + env: { + META_API_KEY: "test-secret", + MUSE_NO_AUTO_UPDATE: "1", + }, + }); + expect(prompt).toBe( + `Inspect these attachments.\n\nThe user attached file "notes.md" at path: ${filePath}`, + ); + + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining([ + "session.started", + "thread.started", + "turn.started", + "item.started", + "item.updated", + "content.delta", + "item.completed", + "turn.completed", + ]), + ); + expect( + events + .filter((event) => event.type === "content.delta") + .map((event) => [event.payload.streamKind, event.payload.delta]), + ).toEqual([ + ["reasoning_text", "considering"], + ["assistant_text", "Implemented safely."], + ]); + expect( + events.find( + (event) => + event.type === "item.updated" && event.payload.itemType === "command_execution", + ), + ).toMatchObject({ payload: { detail: "tests passed", status: "inProgress" } }); + expect( + events.find( + (event) => + event.type === "item.completed" && + event.payload.detail === "Muse tool timed out.", + ), + ).toMatchObject({ payload: { status: "failed" } }); + expect(events.find((event) => event.type === "content.delta")?.raw?.source).toBe( + "muse.exec.event", + ); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { state: "completed" }, + }); + }), + spawner, + ); + }), + ), + ); + + it.effect("emits a proposed plan and uses a read-only planning prompt", () => + Effect.scoped( + Effect.gen(function* () { + let spawned: CapturedCommand | undefined; + let prompt = ""; + const spawner = mockSpawner((command) => + Effect.sync(() => { + spawned = command; + const promptIndex = command.args.indexOf("--prompt-file"); + prompt = NodeFS.readFileSync(command.args[promptIndex + 1]!, "utf8"); + return makeHandle({ stdout: completedOutput("1. Inspect\n2. Implement") }); + }), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-plan-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId, { + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "plan-session" }, + }); + yield* adapter.sendTurn({ + threadId, + input: "Add the feature", + attachments: [], + interactionMode: "plan", + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + + expect(prompt).toBe( + [ + "Planning mode is active. Investigate as needed, then return a concrete implementation plan.", + "Do not modify files or perform other state-changing actions.", + "", + "Add the feature", + ].join("\n"), + ); + expect(spawned?.args).toContain("--disable-write"); + expect(spawned?.args).toContain("--disable-shell"); + expect(spawned?.args).not.toContain("--yolo"); + expect( + events + .filter((event) => event.type === "turn.proposed.delta") + .map((event) => (event.type === "turn.proposed.delta" ? event.payload.delta : "")), + ).toEqual(["1. Inspect\n2. Implement"]); + expect(events.find((event) => event.type === "turn.proposed.completed")).toMatchObject({ + payload: { planMarkdown: "1. Inspect\n2. Implement" }, + }); + expect( + events.some( + (event) => + event.type === "content.delta" && event.payload.streamKind === "assistant_text", + ), + ).toBe(false); + }), + spawner, + ); + }), + ), + ); + + it.effect("queues a follow-up turn without overlapping Muse processes for one session", () => + Effect.scoped( + Effect.gen(function* () { + const firstExit = yield* Deferred.make(); + const secondExit = yield* Deferred.make(); + const firstSpawned = yield* Deferred.make(); + const secondSpawned = yield* Deferred.make(); + const secondSendStarted = yield* Deferred.make(); + const commands: CapturedCommand[] = []; + let activeProcesses = 0; + let maxActiveProcesses = 0; + + const spawner = mockSpawner((command) => + Effect.gen(function* () { + const spawnIndex = commands.length; + commands.push(command); + activeProcesses += 1; + maxActiveProcesses = Math.max(maxActiveProcesses, activeProcesses); + yield* Deferred.succeed( + spawnIndex === 0 ? firstSpawned : secondSpawned, + undefined, + ).pipe(Effect.ignore); + const processExit = spawnIndex === 0 ? firstExit : secondExit; + return makeHandle({ + stdout: completedOutput(spawnIndex === 0 ? "first complete" : "second complete"), + exitCode: Deferred.await(processExit).pipe( + Effect.tap(() => + Effect.sync(() => { + activeProcesses -= 1; + }), + ), + ), + }); + }), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-queued-turn-test"); + yield* startMuseSession(adapter, threadId, { + resumeCursor: { schemaVersion: 1, sessionId: "serialized-session" }, + }); + + const firstTurnFiber = yield* adapter + .sendTurn({ threadId, input: "first", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSpawned); + const secondTurnFiber = yield* Effect.gen(function* () { + yield* Deferred.succeed(secondSendStarted, undefined).pipe(Effect.ignore); + return yield* adapter.sendTurn({ threadId, input: "second", attachments: [] }); + }).pipe(Effect.forkChild); + yield* Deferred.await(secondSendStarted); + for (let attempt = 0; attempt < 4; attempt += 1) { + yield* Effect.yieldNow; + } + + expect(commands).toHaveLength(1); + expect(activeProcesses).toBe(1); + + yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(0)); + yield* Deferred.await(secondSpawned); + + expect(commands).toHaveLength(2); + expect(activeProcesses).toBe(1); + expect(maxActiveProcesses).toBe(1); + expect( + commands.map((command) => { + const sessionIndex = command.args.indexOf("--session-id"); + return command.args[sessionIndex + 1]; + }), + ).toEqual(["serialized-session", "serialized-session"]); + + yield* Deferred.succeed(secondExit, ChildProcessSpawner.ExitCode(0)); + const [firstTurn, secondTurn] = yield* Effect.all([ + Fiber.join(firstTurnFiber), + Fiber.join(secondTurnFiber), + ]); + expect(firstTurn.turnId).not.toBe(secondTurn.turnId); + expect(activeProcesses).toBe(0); + expect((yield* adapter.readThread(threadId)).turns).toHaveLength(2); + }), + spawner, + ); + }), + ), + ); + + it.effect("maps a non-zero Muse exit and stderr to canonical failure state", () => + Effect.scoped( + Effect.gen(function* () { + const spawner = mockSpawner(() => + Effect.succeed( + makeHandle({ + stderr: "Meta authentication failed\n", + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(7)), + }), + ), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-process-failure-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId); + yield* adapter.sendTurn({ threadId, input: "fail", attachments: [] }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const sessions = yield* adapter.listSessions(); + + expect(events.find((event) => event.type === "runtime.error")).toMatchObject({ + payload: { + message: "Meta authentication failed", + class: "provider_error", + }, + }); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "Meta authentication failed", + }, + }); + expect(sessions[0]).toMatchObject({ + status: "error", + lastError: "Meta authentication failed", + }); + }), + spawner, + ); + }), + ), + ); + + it.effect("kills an active Muse process and completes the turn as interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const exitCode = yield* Deferred.make(); + const spawned = yield* Deferred.make(); + let killCount = 0; + const spawner = mockSpawner(() => + Effect.succeed( + makeHandle({ + stdout: [ + museEvent( + "task.lifecycle.proposed", + { + task_id: "interrupted-tool", + event: { + kind: "proposed", + task_id: "interrupted-tool", + task_kind: "tool.bash", + }, + }, + 1, + ), + museEvent( + "task.lifecycle.started", + { + task_id: "interrupted-tool", + event: { kind: "started", task_id: "interrupted-tool" }, + }, + 2, + ), + "", + ].join("\n"), + exitCode: Deferred.succeed(spawned, undefined).pipe( + Effect.andThen(Deferred.await(exitCode)), + ), + onKill: () => + Effect.sync(() => { + killCount += 1; + }).pipe( + Effect.andThen( + Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(130)).pipe( + Effect.ignore, + ), + ), + ), + }), + ), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-interrupt-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "keep running", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(spawned); + + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(turnFiber); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const sessions = yield* adapter.listSessions(); + + expect(killCount).toBeGreaterThanOrEqual(1); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { state: "interrupted" }, + }); + expect( + events.find( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ), + ).toMatchObject({ + payload: { + status: "failed", + detail: "Muse Code turn was interrupted.", + }, + }); + expect(sessions[0]).toMatchObject({ status: "ready" }); + expect(sessions[0]?.activeTurnId).toBeUndefined(); + }), + spawner, + ); + }), + ), + ); + + it.effect("settles the turn and session when the Muse process defects", () => + Effect.scoped( + Effect.gen(function* () { + const spawner = mockSpawner(() => + Effect.succeed( + makeHandle({ + exitCode: Effect.die("unexpected process defect"), + }), + ), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-defect-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId); + + const turnExit = yield* adapter + .sendTurn({ threadId, input: "trigger a defect", attachments: [] }) + .pipe(Effect.exit); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const sessions = yield* adapter.listSessions(); + + expect(turnExit._tag).toBe("Failure"); + expect(events.find((event) => event.type === "runtime.error")).toMatchObject({ + payload: { + message: "Muse Code turn ended unexpectedly.", + class: "provider_error", + }, + }); + expect(events.at(-1)).toMatchObject({ + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "Muse Code turn ended unexpectedly.", + }, + }); + expect(sessions[0]).toMatchObject({ + status: "error", + lastError: "Muse Code turn ended unexpectedly.", + }); + expect(sessions[0]?.activeTurnId).toBeUndefined(); + }), + spawner, + ); + }), + ), + ); + + it.effect("does not resurrect a session when stop races an active turn", () => + Effect.scoped( + Effect.gen(function* () { + const exitCode = yield* Deferred.make(); + const spawned = yield* Deferred.make(); + const observedTypes: string[] = []; + const spawner = mockSpawner(() => + Effect.succeed( + makeHandle({ + stdout: completedOutput("too late"), + exitCode: Deferred.succeed(spawned, undefined).pipe( + Effect.andThen(Deferred.await(exitCode)), + ), + onKill: () => + Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(143)).pipe(Effect.asVoid), + }), + ), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-stop-race-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.runForEach((event) => + Effect.sync(() => { + observedTypes.push(event.type); + }), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "keep running", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(spawned); + + yield* adapter.stopSession(threadId); + const turnExit = yield* Fiber.await(turnFiber); + for (let attempt = 0; attempt < 4; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(eventsFiber); + + expect(turnExit._tag).toBe("Failure"); + expect(yield* adapter.hasSession(threadId)).toBe(false); + const exitedIndex = observedTypes.indexOf("session.exited"); + const completedIndex = observedTypes.indexOf("turn.completed"); + expect(exitedIndex).toBeGreaterThanOrEqual(0); + expect(completedIndex).toBeGreaterThanOrEqual(0); + expect(completedIndex).toBeLessThan(exitedIndex); + expect(observedTypes.slice(exitedIndex + 1)).not.toContain("turn.completed"); + }), + spawner, + ); + }), + ), + ); + + it.effect("waits for an in-flight spawn to cancel before the stopped session exits", () => + Effect.scoped( + Effect.gen(function* () { + const spawnEntered = yield* Deferred.make(); + const releaseSpawn = yield* Deferred.make(); + const childExit = yield* Deferred.make(); + const stopDone = yield* Deferred.make(); + const observedTypes: string[] = []; + let spawnReturned = 0; + let killCount = 0; + let spawnedHandle: ChildProcessSpawner.ChildProcessHandle | undefined; + const spawner = mockSpawner(() => + Effect.uninterruptible( + Effect.gen(function* () { + yield* Deferred.succeed(spawnEntered, undefined); + yield* Deferred.await(releaseSpawn); + const handle = makeHandle({ + exitCode: Deferred.await(childExit), + onKill: () => + Effect.sync(() => { + killCount += 1; + }).pipe( + Effect.andThen( + Deferred.succeed(childExit, ChildProcessSpawner.ExitCode(143)).pipe( + Effect.ignore, + ), + ), + ), + }); + spawnedHandle = handle; + spawnReturned += 1; + return handle; + }), + ).pipe( + Effect.onInterrupt(() => + spawnedHandle + ? spawnedHandle.kill({ forceKillAfter: 2_000 }).pipe(Effect.ignore) + : Effect.void, + ), + ), + ); + + yield* provideMuseTestServices( + Effect.gen(function* () { + const adapter = yield* makeMuseAdapter( + decodeMuseSettings({ binaryPath: process.execPath }), + ); + const threadId = ThreadId.make("muse-stop-during-spawn-test"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.runForEach((event) => + Effect.sync(() => { + observedTypes.push(event.type); + }), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* startMuseSession(adapter, threadId); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "stop while spawning", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(spawnEntered); + + const stopFiber = yield* adapter + .stopSession(threadId) + .pipe( + Effect.ensuring(Deferred.succeed(stopDone, undefined).pipe(Effect.ignore)), + Effect.forkChild, + ); + yield* Effect.yieldNow; + expect(yield* Deferred.isDone(stopDone)).toBe(false); + + yield* Deferred.succeed(releaseSpawn, undefined); + yield* Fiber.join(stopFiber); + const turnExit = yield* Fiber.await(turnFiber); + yield* Effect.yieldNow; + yield* Fiber.interrupt(eventsFiber); + + expect(turnExit._tag).toBe("Failure"); + expect(spawnReturned).toBe(1); + expect(killCount).toBeGreaterThanOrEqual(1); + expect(yield* adapter.hasSession(threadId)).toBe(false); + const exitedIndex = observedTypes.indexOf("session.exited"); + expect(exitedIndex).toBeGreaterThanOrEqual(0); + expect(observedTypes.filter((type) => type === "turn.completed")).toHaveLength(1); + expect(observedTypes.slice(exitedIndex + 1)).toEqual([]); + }), + spawner, + ); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/MuseAdapter.ts b/apps/server/src/provider/Layers/MuseAdapter.ts new file mode 100644 index 000000000000..196214efe708 --- /dev/null +++ b/apps/server/src/provider/Layers/MuseAdapter.ts @@ -0,0 +1,1319 @@ +/** + * MuseAdapter — first-class T3 runtime adapter for `muse exec --json`. + * + * Muse currently exposes a process-per-turn headless protocol rather than a + * long-lived RPC server. A durable Muse session UUID supplies continuation; + * every T3 turn launches one scoped process against that UUID and translates + * the JSONL stream into canonical provider runtime events. + */ + +import { + EventId, + type MuseSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + RuntimeItemId, + type RuntimeMode, + ThreadId, + type ToolLifecycleItemType, + TurnId, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +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 FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { MuseAdapterShape } from "../Services/MuseAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + filterMuseLaunchArgs, + isMuseUserVisibleTaskKind, + museOutputDelta, + musePlanDelta, + museReasoningDelta, + museTaskLifecycle, + museTerminalRecord, + parseMuseJsonLine, + type MuseJsonEvent, + type MuseTerminalRecord, +} from "./MuseProtocol.ts"; + +const PROVIDER = ProviderDriverKind.make("muse"); +const MUSE_RESUME_VERSION = 1 as const; +const DEFAULT_MUSE_MODEL = "muse-spark-1.2"; +const DEFAULT_REASONING_EFFORT = "high"; +const MAX_DIAGNOSTIC_CHARS = 16_000; +const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); + +const MUSE_REASONING_EFFORTS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultra", +]); + +interface MuseTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface MuseActiveRun { + readonly turnId: TurnId; + readonly cancelRequested: Deferred.Deferred; + readonly done: Deferred.Deferred; + child: ChildProcessSpawner.ChildProcessHandle | undefined; + interrupted: boolean; + phase: "open" | "settling" | "settled"; +} + +interface MuseSessionContext { + session: ProviderSession; + readonly museSessionId: string; + readonly approvalPolicy: "untrusted" | "on-failure" | "on-request" | "never" | undefined; + readonly sandboxMode: "read-only" | "workspace-write" | "danger-full-access" | undefined; + readonly turns: Array; + activeRun: MuseActiveRun | undefined; + stopped: boolean; +} + +interface MuseTaskState { + taskKind: string | undefined; + operation: string | undefined; + started: boolean; + completed: boolean; +} + +export interface MuseAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface BuildMuseExecArgsInput { + readonly sessionId: string; + readonly cwd: string; + readonly promptFile: string; + readonly model: string; + readonly reasoningEffort: string; + readonly runtimeMode: RuntimeMode; + readonly approvalPolicy?: "untrusted" | "on-failure" | "on-request" | "never" | undefined; + readonly sandboxMode?: "read-only" | "workspace-write" | "danger-full-access" | undefined; + readonly interactionMode?: ProviderInteractionMode | undefined; + readonly imagePaths: ReadonlyArray; + readonly launchArgs?: string | undefined; +} + +export function buildMuseExecArgs(input: BuildMuseExecArgsInput): ReadonlyArray { + const args = [ + "exec", + ...filterMuseLaunchArgs(input.launchArgs), + "--json", + "--provider", + "meta", + "--session-id", + input.sessionId, + "--workspace", + input.cwd, + "--prompt-file", + input.promptFile, + "--model", + input.model, + "--reasoning-effort", + input.reasoningEffort, + "--parallel-tool-calls", + ]; + + // RuntimeMode is the safety authority. Muse headless has no channel for + // answering a mid-run approval prompt and otherwise waits forever after + // emitting only to its retained session log. Plan and approval-required + // therefore become sandboxed read-only runs; automatic modes retain Muse's + // default sandbox but do not block on an unreachable prompt. + const readOnly = input.interactionMode === "plan" || input.runtimeMode === "approval-required"; + if (input.runtimeMode === "full-access" && !readOnly) { + args.push("--yolo"); + } else { + args.push("--trust-workspace", "--disable-approval"); + if (readOnly) { + args.push("--disable-write", "--disable-shell"); + } + } + for (const imagePath of input.imagePaths) { + args.push("--image", imagePath); + } + return args; +} + +function parseMuseResume(raw: unknown): { readonly sessionId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== MUSE_RESUME_VERSION) { + return undefined; + } + if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { + return undefined; + } + return { sessionId: record.sessionId.trim() }; +} + +function museResumeCursor(sessionId: string) { + return { schemaVersion: MUSE_RESUME_VERSION, sessionId } as const; +} + +function reasoningEffortFromTurn(input: ProviderSendTurnInput): string { + const requested = getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort"); + return requested && MUSE_REASONING_EFFORTS.has(requested) ? requested : DEFAULT_REASONING_EFFORT; +} + +function planPrompt(prompt: string): string { + return [ + "Planning mode is active. Investigate as needed, then return a concrete implementation plan.", + "Do not modify files or perform other state-changing actions.", + "", + prompt, + ].join("\n"); +} + +function terminalState( + terminal: MuseTerminalRecord | undefined, + interrupted: boolean, + exitCode: number, +): "completed" | "failed" | "interrupted" | "cancelled" { + if (interrupted) { + return "interrupted"; + } + const value = terminal?.terminal.toLowerCase(); + if (value === "completed" || value === "success" || value === "succeeded") { + return exitCode === 0 ? "completed" : "failed"; + } + if (value === "interrupted") { + return "interrupted"; + } + if (value === "cancelled" || value === "canceled") { + return "cancelled"; + } + return "failed"; +} + +function toolItemType(taskKind: string): ToolLifecycleItemType { + const normalized = taskKind.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") + ) { + return "command_execution"; + } + if (normalized.includes("write") || normalized.includes("edit") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("web") || normalized.includes("search")) { + return "web_search"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if (normalized.includes("agent") || normalized.includes("workflow")) { + return "collab_agent_tool_call"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; +} + +function taskOutputDetail(event: MuseJsonEvent): string | undefined { + const nested = event.payload.event; + if (typeof nested !== "object" || nested === null || Array.isArray(nested)) { + return undefined; + } + const chunk = (nested as Record).chunk; + if (typeof chunk !== "string" || chunk.trim().length === 0) { + return undefined; + } + const parsed = Option.getOrUndefined(decodeUnknownJson(chunk)); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const record = parsed as Record; + for (const key of ["output", "description", "command"] as const) { + if (typeof record[key] === "string" && record[key].trim().length > 0) { + return record[key].slice(0, 4_000); + } + } + } + return chunk.slice(0, 4_000); +} + +function appendDiagnostic(current: string, line: string): string { + if (line.length === 0 || current.length >= MAX_DIAGNOSTIC_CHARS) { + return current; + } + const next = current.length === 0 ? line : `${current}\n${line}`; + return next.slice(0, MAX_DIAGNOSTIC_CHARS); +} + +function suffixNotAlreadyEmitted(emitted: string, complete: string): string { + if (complete.startsWith(emitted)) { + return complete.slice(emitted.length); + } + return emitted.length === 0 ? complete : ""; +} + +export const makeMuseAdapter = Effect.fn("makeMuseAdapter")(function* ( + museSettings: MuseSettings, + options?: MuseAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("muse"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const serverConfig = yield* ServerConfig; + const environment = { + ...(options?.environment ?? process.env), + MUSE_NO_AUTO_UPDATE: options?.environment?.MUSE_NO_AUTO_UPDATE ?? "1", + } satisfies NodeJS.ProcessEnv; + const sensitiveEnvironmentValues = Object.entries(environment).flatMap(([name, value]) => + value && value.length >= 4 && /(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(name) + ? [value] + : [], + ); + const redactDiagnostic = (value: string) => + sensitiveEnvironmentValues.reduce( + (current, sensitive) => current.replaceAll(sensitive, ""), + value, + ); + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const eventPubSub = yield* PubSub.unbounded(); + const nativeEventLogger = options?.nativeEventLogger; + + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate a Muse runtime identifier.", + cause, + }), + ), + ); + const eventStamp = () => + Effect.all({ + eventId: Effect.map(randomUUIDv4, EventId.make), + createdAt: Effect.map(DateTime.now, DateTime.formatIso), + }); + const emit = (event: ProviderRuntimeEvent) => + PubSub.publish(eventPubSub, event).pipe(Effect.asVoid); + const eventBase = Effect.fn("MuseAdapter.eventBase")(function* (input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: RuntimeItemId | undefined; + readonly rawEvent?: MuseJsonEvent | undefined; + }) { + return { + ...(yield* eventStamp()), + provider: PROVIDER, + providerInstanceId: boundInstanceId, + threadId: input.threadId, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: input.itemId } : {}), + ...(input.rawEvent + ? { + raw: { + source: "muse.exec.event" as const, + messageType: input.rawEvent.payload_type, + payload: input.rawEvent, + }, + } + : {}), + }; + }); + + const logNative = (threadId: ThreadId, event: unknown) => + nativeEventLogger + ? DateTime.now.pipe( + Effect.map(DateTime.formatIso), + Effect.flatMap((observedAt) => nativeEventLogger.write({ observedAt, event }, threadId)), + ) + : Effect.void; + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const context = sessions.get(threadId); + return context && !context.stopped + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = Option.fromNullishOr(current.get(threadId)); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const stopContext = (context: MuseSessionContext) => + Effect.uninterruptible( + Effect.gen(function* () { + if (context.stopped) return; + context.stopped = true; + const activeRun = context.activeRun; + if (activeRun) { + if (activeRun.phase === "open") { + activeRun.interrupted = true; + yield* Deferred.succeed(activeRun.cancelRequested, undefined).pipe(Effect.ignore); + } + if (activeRun.child) { + yield* activeRun.child.kill({ forceKillAfter: 2_000 }).pipe(Effect.ignore); + } + // The send fiber exclusively owns terminal event settlement. Waiting + // here guarantees session.exited is always the final event. + yield* Deferred.await(activeRun.done); + } + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + yield* emit({ + ...(yield* eventBase({ threadId: context.session.threadId })), + type: "session.exited", + payload: { exitKind: "graceful" }, + }); + }), + ); + + const startSessionUnlocked: MuseAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (input.providerInstanceId !== undefined && input.providerInstanceId !== boundInstanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider instance '${boundInstanceId}' but received '${input.providerInstanceId}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== boundInstanceId + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Muse model selection is bound to instance '${input.modelSelection.instanceId}', expected '${boundInstanceId}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + yield* stopContext(existing); + } + const museSessionId = parseMuseResume(input.resumeCursor)?.sessionId ?? (yield* randomUUIDv4); + const now = DateTime.formatIso(yield* DateTime.now); + const cwd = path.resolve(input.cwd.trim()); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: input.modelSelection?.model ?? DEFAULT_MUSE_MODEL, + threadId: input.threadId, + resumeCursor: museResumeCursor(museSessionId), + createdAt: now, + updatedAt: now, + }; + sessions.set(input.threadId, { + session, + museSessionId, + approvalPolicy: input.approvalPolicy, + sandboxMode: input.sandboxMode, + turns: [], + activeRun: undefined, + stopped: false, + }); + + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "Muse Code session ready", + resume: museResumeCursor(museSessionId), + }, + }); + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId })), + type: "session.state.changed", + payload: { state: "ready", reason: "Muse Code session ready" }, + }); + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { providerThreadId: museSessionId }, + }); + return session; + }); + + const startSession: MuseAdapterShape["startSession"] = (input) => + withThreadLock(input.threadId, startSessionUnlocked(input)); + + const sendTurnUnlocked: MuseAdapterShape["sendTurn"] = Effect.fn("MuseAdapter.sendTurn")( + function* (input) { + const context = yield* requireSession(input.threadId); + if (context.activeRun) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "exec", + detail: + "Muse Code cannot accept a steering message while its headless turn is running. Interrupt the turn and send the message again.", + }); + } + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== boundInstanceId + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Muse model selection is bound to instance '${input.modelSelection.instanceId}', expected '${boundInstanceId}'.`, + }); + } + + const text = input.input?.trim() ?? ""; + if (text.length === 0 && (input.attachments?.length ?? 0) === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Muse turns require text input or at least one attachment.", + }); + } + + const imagePaths: Array = []; + const fileDescriptions: Array = []; + for (const attachment of input.attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "exec", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + if (attachment.type === "image") { + imagePaths.push(attachmentPath); + } else { + const safeName = attachment.name.replaceAll("\r", " ").replaceAll("\n", " "); + fileDescriptions.push(`The user attached file "${safeName}" at path: ${attachmentPath}`); + } + } + + const basePrompt = [ + text || (imagePaths.length > 0 ? "Please analyze the attached image." : ""), + ...fileDescriptions, + ] + .filter((part) => part.length > 0) + .join("\n\n"); + const prompt = input.interactionMode === "plan" ? planPrompt(basePrompt) : basePrompt; + const turnId = TurnId.make(`muse-turn-${yield* randomUUIDv4}`); + const model = input.modelSelection?.model ?? context.session.model ?? DEFAULT_MUSE_MODEL; + const reasoningEffort = reasoningEffortFromTurn(input); + const startedAt = DateTime.formatIso(yield* DateTime.now); + const turn: MuseTurnSnapshot = { + id: turnId, + items: [{ role: "user", text: basePrompt, attachments: input.attachments ?? [] }], + }; + const assistantItemId = RuntimeItemId.make(`muse-assistant-${turnId}`); + const reasoningItemId = RuntimeItemId.make(`muse-reasoning-${turnId}`); + let assistantStarted = false; + let reasoningStarted = false; + let assistantText = ""; + let reasoningText = ""; + let planText = ""; + let terminal: MuseTerminalRecord | undefined; + let diagnostics = ""; + const tasks = new Map(); + const cancelRequested = yield* Deferred.make(); + const done = yield* Deferred.make(); + const activeRun: MuseActiveRun = { + turnId, + cancelRequested, + done, + child: undefined, + interrupted: false, + phase: "open", + }; + + const ensureItemStarted = Effect.fn("MuseAdapter.ensureItemStarted")(function* ( + itemId: RuntimeItemId, + itemType: "assistant_message" | "reasoning", + rawEvent: MuseJsonEvent, + ) { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent })), + type: "item.started", + payload: { itemType, status: "inProgress" }, + }); + }); + + const handleTaskLifecycle = Effect.fn("MuseAdapter.handleTaskLifecycle")(function* ( + event: MuseJsonEvent, + ) { + const lifecycle = museTaskLifecycle(event); + if (!lifecycle) return; + const current = tasks.get(lifecycle.taskId) ?? { + taskKind: undefined, + operation: undefined, + started: false, + completed: false, + }; + if (lifecycle.taskKind) current.taskKind = lifecycle.taskKind; + if (lifecycle.operation) current.operation = lifecycle.operation; + tasks.set(lifecycle.taskId, current); + const taskKind = current.taskKind ?? current.operation; + if (!taskKind || !isMuseUserVisibleTaskKind(taskKind)) return; + const itemId = RuntimeItemId.make(`muse-task-${lifecycle.taskId}`); + const itemType = toolItemType(taskKind); + const title = current.operation ?? current.taskKind ?? "Muse tool"; + + if (lifecycle.lifecycle === "started" && !current.started) { + current.started = true; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent: event })), + type: "item.started", + payload: { itemType, status: "inProgress", title, data: event.payload }, + }); + return; + } + if (lifecycle.lifecycle === "output") { + if (!current.started) { + current.started = true; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent: event })), + type: "item.started", + payload: { itemType, status: "inProgress", title, data: event.payload }, + }); + } + const detail = taskOutputDetail(event); + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent: event })), + type: "item.updated", + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + data: event.payload, + }, + }); + return; + } + if ( + (lifecycle.lifecycle === "completed" || + lifecycle.lifecycle === "failed" || + lifecycle.lifecycle === "cancelled" || + lifecycle.lifecycle === "timed_out") && + !current.completed + ) { + if (!current.started) { + current.started = true; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent: event })), + type: "item.started", + payload: { itemType, status: "inProgress", title, data: event.payload }, + }); + } + current.completed = true; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, itemId, rawEvent: event })), + type: "item.completed", + payload: { + itemType, + status: lifecycle.lifecycle === "completed" ? "completed" : "failed", + title, + ...(lifecycle.reason + ? { detail: lifecycle.reason } + : lifecycle.lifecycle === "timed_out" + ? { detail: "Muse tool timed out." } + : {}), + data: event.payload, + }, + }); + } + }); + + const settleOpenTasks = Effect.fn("MuseAdapter.settleOpenTasks")(function* ( + state: "completed" | "failed" | "interrupted" | "cancelled", + detail?: string, + ) { + for (const [taskId, task] of tasks) { + if (!task.started || task.completed) continue; + task.completed = true; + const taskKind = task.taskKind ?? task.operation ?? "Muse tool"; + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: RuntimeItemId.make(`muse-task-${taskId}`), + })), + type: "item.completed", + payload: { + itemType: toolItemType(taskKind), + status: state === "completed" ? "completed" : "failed", + title: task.operation ?? task.taskKind ?? "Muse tool", + ...(detail ? { detail } : {}), + }, + }); + } + }); + + const settleRun = ( + body: (interrupted: boolean) => Effect.Effect, + ): Effect.Effect => + Effect.uninterruptible( + Effect.suspend(() => { + if (activeRun.phase !== "open") { + return Deferred.await(activeRun.done); + } + + // This synchronous transition is the turn's linearization point. + // A stop that wins first sets interrupted while the run is open; a + // stop after this point waits for this owner and cannot replace its + // terminal outcome. + activeRun.phase = "settling"; + const interrupted = activeRun.interrupted; + let settlementCompleted = false; + + return body(interrupted).pipe( + Effect.tap(() => + Effect.sync(() => { + settlementCompleted = true; + }), + ), + Effect.ensuring( + Effect.gen(function* () { + if (context.activeRun === activeRun) { + context.activeRun = undefined; + } + const { activeTurnId: _activeTurnId, ...sessionWithoutActiveTurn } = + context.session; + context.session = settlementCompleted + ? sessionWithoutActiveTurn + : { + ...sessionWithoutActiveTurn, + status: "error", + lastError: "Muse Code turn settlement failed.", + updatedAt: DateTime.formatIso(yield* DateTime.now), + }; + activeRun.phase = "settled"; + yield* Deferred.succeed(activeRun.done, undefined).pipe(Effect.ignore); + }), + ), + ); + }), + ); + + const finalizeAbandonedRun = () => + settleRun((interrupted) => + Effect.gen(function* () { + if (activeRun.child) { + yield* activeRun.child.kill({ forceKillAfter: 2_000 }).pipe(Effect.ignore); + } + + const state = interrupted ? "interrupted" : "failed"; + const errorMessage = interrupted + ? "Muse Code turn was interrupted." + : "Muse Code turn ended unexpectedly."; + yield* settleOpenTasks(state, errorMessage); + if (reasoningStarted) { + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: reasoningItemId, + })), + type: "item.completed", + payload: { itemType: "reasoning", status: "failed" }, + }); + } + if (assistantStarted) { + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: assistantItemId, + })), + type: "item.completed", + payload: { itemType: "assistant_message", status: "failed" }, + }); + } + if (!interrupted) { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "runtime.error", + payload: { message: errorMessage, class: "provider_error" }, + }); + } + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { + state, + stopReason: interrupted ? "interrupted" : null, + ...(!interrupted ? { errorMessage } : {}), + }, + }); + turn.items.push({ + role: input.interactionMode === "plan" ? "plan" : "assistant", + text: input.interactionMode === "plan" ? planText : assistantText, + reasoning: reasoningText, + state, + }); + if (!context.stopped) { + const { activeTurnId: _activeTurnId, ...settledSession } = context.session; + context.session = { + ...settledSession, + status: state === "failed" ? "error" : "ready", + updatedAt: DateTime.formatIso(yield* DateTime.now), + ...(state === "failed" ? { lastError: errorMessage } : { lastError: undefined }), + }; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "session.state.changed", + payload: + state === "failed" + ? { state: "error", reason: errorMessage } + : { state: "ready", reason: "Muse Code turn interrupted" }, + }); + } + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to settle an abandoned Muse Code turn.", { cause }), + ), + ); + + // All potentially yielding preparation is complete. Publish ownership + // synchronously so stopSession either sees this run or wins before it. + if (context.stopped || sessions.get(input.threadId) !== context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + context.activeRun = activeRun; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + model, + updatedAt: startedAt, + lastError: undefined, + }; + context.turns.push(turn); + + return yield* Effect.gen(function* () { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { model, effort: reasoningEffort }, + }); + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "session.state.changed", + payload: { state: "running", reason: "Muse Code turn started" }, + }); + if (context.session.runtimeMode === "approval-required") { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "runtime.warning", + payload: { + message: + "Muse Code headless mode cannot relay interactive approvals, so this approval-required turn is read-only. Use Auto or Full Access to allow changes.", + }, + }); + } + + const handleEventUnlocked = Effect.fn("MuseAdapter.handleEvent")(function* ( + event: MuseJsonEvent, + ) { + if (context.stopped) return; + yield* logNative(input.threadId, event); + const task = museTaskLifecycle(event); + if (task) { + yield* handleTaskLifecycle(event); + } + + const reasoningDelta = museReasoningDelta(event); + if (reasoningDelta !== undefined && reasoningDelta.length > 0) { + if (!reasoningStarted) { + reasoningStarted = true; + yield* ensureItemStarted(reasoningItemId, "reasoning", event); + } + reasoningText += reasoningDelta; + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: reasoningItemId, + rawEvent: event, + })), + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: reasoningDelta }, + }); + } + + const nativePlanDelta = musePlanDelta(event); + const outputDelta = museOutputDelta(event); + const proposedDelta = + nativePlanDelta ?? (input.interactionMode === "plan" ? outputDelta : undefined); + if (proposedDelta !== undefined && proposedDelta.length > 0) { + planText += proposedDelta; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId, rawEvent: event })), + type: "turn.proposed.delta", + payload: { delta: proposedDelta }, + }); + } else if (outputDelta !== undefined && outputDelta.length > 0) { + if (!assistantStarted) { + assistantStarted = true; + yield* ensureItemStarted(assistantItemId, "assistant_message", event); + } + assistantText += outputDelta; + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: assistantItemId, + rawEvent: event, + })), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: outputDelta }, + }); + } + + terminal = museTerminalRecord(event) ?? terminal; + }); + const handleEvent = (event: MuseJsonEvent) => + Effect.uninterruptible(handleEventUnlocked(event)); + + const runProcess = Effect.gen(function* () { + const promptFile = yield* fileSystem.makeTempFileScoped({ + prefix: "t3-muse-prompt-", + suffix: ".md", + }); + yield* fileSystem.writeFileString(promptFile, prompt); + const args = buildMuseExecArgs({ + sessionId: context.museSessionId, + cwd: context.session.cwd ?? process.cwd(), + promptFile, + model, + reasoningEffort, + runtimeMode: context.session.runtimeMode, + approvalPolicy: context.approvalPolicy, + sandboxMode: context.sandboxMode, + interactionMode: input.interactionMode, + imagePaths, + launchArgs: museSettings.launchArgs, + }); + const binaryPath = museSettings.binaryPath || "muse"; + const resolved = yield* resolveSpawnCommand(binaryPath, args, { env: environment }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + if (yield* Deferred.isDone(activeRun.cancelRequested)) { + return yield* Effect.interrupt; + } + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { + cwd: context.session.cwd, + env: environment, + shell: resolved.shell, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + forceKillAfter: 2_000, + }), + ); + activeRun.child = child; + if (activeRun.interrupted || context.stopped) { + yield* child.kill({ forceKillAfter: 2_000 }).pipe(Effect.ignore); + } + + const stdoutDrain = child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => { + const parsed = parseMuseJsonLine(line); + if (parsed.kind === "event") { + return handleEvent(parsed.event); + } + const safeText = redactDiagnostic(parsed.text); + diagnostics = appendDiagnostic(diagnostics, safeText); + return parsed.text.length > 0 + ? logNative(input.threadId, { kind: "stdout-diagnostic", text: safeText }) + : Effect.void; + }), + ); + const stderrDrain = child.stderr.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => { + const safeLine = redactDiagnostic(line); + diagnostics = appendDiagnostic(diagnostics, safeLine); + return line.trim().length > 0 + ? logNative(input.threadId, { kind: "stderr", text: safeLine }) + : Effect.void; + }), + ); + const [, , exitCode] = yield* Effect.all( + [stdoutDrain, stderrDrain, child.exitCode.pipe(Effect.map(Number))], + { concurrency: "unbounded" }, + ); + return exitCode; + }).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message ?? "Muse Code process failed.", + cause, + }), + ), + ); + const runOutcome = yield* Deferred.isDone(activeRun.cancelRequested).pipe( + Effect.flatMap((cancelledBeforeLaunch) => + cancelledBeforeLaunch + ? Effect.succeed({ _tag: "Cancelled" } as const) + : Effect.raceFirst( + runProcess.pipe( + Effect.result, + Effect.map((result) => ({ _tag: "Process" as const, result })), + ), + Deferred.await(activeRun.cancelRequested).pipe( + Effect.as({ _tag: "Cancelled" } as const), + ), + ), + ), + ); + const runExitCode = + runOutcome._tag === "Cancelled" + ? 130 + : Result.match(runOutcome.result, { + onFailure: (failure) => { + diagnostics = appendDiagnostic(diagnostics, redactDiagnostic(failure.message)); + return 1; + }, + onSuccess: (exitCode) => exitCode, + }); + + yield* settleRun((interrupted) => + Effect.gen(function* () { + const state = terminalState(terminal, interrupted, runExitCode); + const terminalText = terminal?.text ?? ""; + if (input.interactionMode === "plan") { + const missingPlanSuffix = suffixNotAlreadyEmitted(planText, terminalText); + if (missingPlanSuffix.length > 0) { + planText += missingPlanSuffix; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "turn.proposed.delta", + payload: { delta: missingPlanSuffix }, + }); + } + if (state === "completed" && planText.trim().length > 0) { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "turn.proposed.completed", + payload: { planMarkdown: planText.trim() }, + }); + } + } else { + const missingAssistantSuffix = suffixNotAlreadyEmitted(assistantText, terminalText); + if (missingAssistantSuffix.length > 0) { + if (!assistantStarted) { + assistantStarted = true; + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: assistantItemId, + })), + type: "item.started", + payload: { itemType: "assistant_message", status: "inProgress" }, + }); + } + assistantText += missingAssistantSuffix; + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: assistantItemId, + })), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: missingAssistantSuffix }, + }); + } + } + + const errorMessage = + terminal?.reason ?? + (state === "failed" + ? diagnostics.trim() || `Muse Code exited with status ${runExitCode}.` + : undefined); + const unsettledItemDetail = + state === "interrupted" + ? "Muse Code turn was interrupted." + : state === "cancelled" + ? "Muse Code turn was cancelled." + : errorMessage; + yield* settleOpenTasks(state, unsettledItemDetail); + + if (reasoningStarted) { + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: reasoningItemId, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: state === "completed" ? "completed" : "failed", + }, + }); + } + if (assistantStarted) { + yield* emit({ + ...(yield* eventBase({ + threadId: input.threadId, + turnId, + itemId: assistantItemId, + })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: state === "completed" ? "completed" : "failed", + }, + }); + } + + if (state === "failed" && errorMessage) { + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "runtime.error", + payload: { message: errorMessage, class: "provider_error" }, + }); + } + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { + state, + stopReason: terminal?.reason ?? terminal?.terminal ?? null, + ...(errorMessage ? { errorMessage } : {}), + }, + }); + + turn.items.push({ + role: input.interactionMode === "plan" ? "plan" : "assistant", + text: input.interactionMode === "plan" ? planText : assistantText, + reasoning: reasoningText, + state, + }); + if (!context.stopped) { + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: DateTime.formatIso(yield* DateTime.now), + ...(errorMessage ? { lastError: errorMessage } : { lastError: undefined }), + }; + yield* emit({ + ...(yield* eventBase({ threadId: input.threadId, turnId })), + type: "session.state.changed", + payload: + state === "failed" + ? { state: "error", reason: errorMessage ?? "Muse Code turn failed" } + : { state: "ready", reason: "Muse Code turn finished" }, + }); + } + }), + ); + + if (context.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: museResumeCursor(context.museSessionId), + }; + }).pipe(Effect.ensuring(finalizeAbandonedRun())); + }, + ); + + // Muse has no external steering endpoint. Serializing turns gives a message + // sent during a run deterministic queued-follow-up semantics, without ever + // running two processes against the same durable Muse log concurrently. + const sendTurn: MuseAdapterShape["sendTurn"] = (input) => + withThreadLock(input.threadId, sendTurnUnlocked(input)); + + const interruptTurn: MuseAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const active = context.activeRun; + if (!active || (turnId !== undefined && active.turnId !== turnId)) return; + if (active.phase !== "open") return; + active.interrupted = true; + yield* Deferred.succeed(active.cancelRequested, undefined).pipe(Effect.ignore); + if (active.child) { + yield* active.child.kill({ forceKillAfter: 2_000 }).pipe(Effect.ignore); + } + }); + + const respondToRequest: MuseAdapterShape["respondToRequest"] = ( + _threadId, + _requestId, + _decision, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: "Muse Code headless mode does not expose interactive approval responses.", + }), + ); + + const respondToUserInput: MuseAdapterShape["respondToUserInput"] = ( + _threadId, + _requestId, + _answers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: "Muse Code headless mode does not expose structured user-input prompts.", + }), + ); + + const readThread: MuseAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + return { threadId, turns: context.turns }; + }); + + const rollbackThread: MuseAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: "Muse Code does not currently expose durable session rollback in headless mode.", + }); + }); + + const stopSession: MuseAdapterShape["stopSession"] = (threadId) => + Effect.flatMap(requireSession(threadId), stopContext); + const listSessions: MuseAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + const hasSession: MuseAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + const stopAll: MuseAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopContext, { discard: true }); + + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.tap(() => PubSub.shutdown(eventPubSub)), + Effect.catch((cause) => Effect.logWarning("Failed to stop Muse sessions.", { cause })), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromPubSub(eventPubSub), + } satisfies MuseAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/MuseProtocol.test.ts b/apps/server/src/provider/Layers/MuseProtocol.test.ts new file mode 100644 index 000000000000..1f8504da131e --- /dev/null +++ b/apps/server/src/provider/Layers/MuseProtocol.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { + filterMuseLaunchArgs, + isMuseUserVisibleTaskKind, + museOutputDelta, + museTaskLifecycle, + museTerminalRecord, + parseMuseJsonLine, +} from "./MuseProtocol.ts"; + +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const envelope = (payloadType: string, payload: Record) => + encodeUnknownJson({ + schema_version: 1, + id: "018f0000-0000-7000-8000-00000000c372", + stream: { kind: "session", id: "33333333-3333-4333-8333-333333333333" }, + sequence: 19, + recorded_at: 1_780_531_400_000_034, + record_type: "status", + durability: "ephemeral", + causation_id: "96a206f2-8207-4f7a-9796-6b3fc98bcb22", + payload_type: payloadType, + payload_schema_version: 1, + payload, + }); + +describe("MuseProtocol", () => { + it("only preserves bounded, non-authoritative launch tuning", () => { + expect( + filterMuseLaunchArgs( + [ + "--max-model-steps=6", + "--max-tool-output-bytes 8192", + "--context-compaction-strategy prefix-extension-summary/v1", + "--context-compaction-hard-threshold .9", + "--disable-web-tools", + "--yolo", + "--sandbox-network enabled", + "--workspace /", + "--unknown-future-flag value", + ].join(" "), + ), + ).toEqual([ + "--max-model-steps", + "6", + "--max-tool-output-bytes", + "8192", + "--context-compaction-strategy", + "prefix-extension-summary/v1", + "--context-compaction-hard-threshold", + ".9", + "--disable-web-tools", + ]); + }); + + it("decodes output and terminal records from Muse JSONL", () => { + const delta = parseMuseJsonLine( + envelope("run.output.delta", { kind: "run_output_delta", text: "hello" }), + ); + expect(delta.kind).toBe("event"); + if (delta.kind !== "event") return; + expect(museOutputDelta(delta.event)).toBe("hello"); + + const terminal = parseMuseJsonLine( + envelope("run.terminal.completed", { + kind: "run_terminal", + terminal: "completed", + text: "hello", + reason: null, + }), + ); + expect(terminal.kind).toBe("event"); + if (terminal.kind !== "event") return; + expect(museTerminalRecord(terminal.event)).toEqual({ + terminal: "completed", + text: "hello", + }); + }); + + it("preserves non-JSON stdout as a diagnostic", () => { + expect(parseMuseJsonLine("muse: starting")).toEqual({ + kind: "diagnostic", + text: "muse: starting", + }); + }); + + it("extracts task lifecycle metadata and filters internal reminder/model tasks", () => { + const parsed = parseMuseJsonLine( + envelope("task.lifecycle.proposed", { + task_id: "task-1", + event: { kind: "proposed", task_id: "task-1", task_kind: "tool.workspace.shell" }, + }), + ); + expect(parsed.kind).toBe("event"); + if (parsed.kind !== "event") return; + expect(museTaskLifecycle(parsed.event)).toEqual({ + taskId: "task-1", + lifecycle: "proposed", + taskKind: "tool.workspace.shell", + }); + expect(isMuseUserVisibleTaskKind("tool.workspace.shell")).toBe(true); + expect(isMuseUserVisibleTaskKind("model.unknown.response")).toBe(false); + expect(isMuseUserVisibleTaskKind("reminder.agent.plugin:verify-reminder")).toBe(false); + }); +}); diff --git a/apps/server/src/provider/Layers/MuseProtocol.ts b/apps/server/src/provider/Layers/MuseProtocol.ts new file mode 100644 index 000000000000..df4b8ece500c --- /dev/null +++ b/apps/server/src/provider/Layers/MuseProtocol.ts @@ -0,0 +1,266 @@ +/** + * Small, defensive decoder for `muse exec --json` records. + * + * Muse's JSONL stream is versioned by the CLI, but the package is not + * published as a TypeScript dependency. Keep the boundary structural and + * preserve unknown payload fields so a newer CLI can add records without + * breaking an existing T3 Code build. + */ + +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); + +const MUSE_CONTEXT_COMPACTION_STRATEGIES = new Set([ + "summary-preserved-suffix/v1", + "prefix-extension-summary/v1", + "prefix-extension-inventory-summary/v1", +]); + +const MUSE_SAFE_BOOLEAN_LAUNCH_ARGS = new Set([ + "--disable-web-tools", + "--no-foreign-personal-context", +]); + +const MUSE_SAFE_VALUE_LAUNCH_ARGS: Readonly boolean>> = { + "--context-compaction-strategy": (value) => MUSE_CONTEXT_COMPACTION_STRATEGIES.has(value), + "--context-compaction-soft-threshold": isMuseCompactionThreshold, + "--context-compaction-hard-threshold": isMuseCompactionThreshold, + "--max-model-steps": isPositiveInteger, + "--max-tool-output-bytes": isPositiveInteger, +}; + +function isPositiveInteger(value: string): boolean { + return /^[1-9]\d*$/u.test(value); +} + +function isMuseCompactionThreshold(value: string): boolean { + if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/u.test(value)) return false; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1; +} + +/** + * Keep provider launch configuration useful without allowing it to replace + * T3 Code's prompt, workspace, provider, session, or runtime safety policy. + * Unknown options are deliberately dropped so a future Muse flag cannot + * silently weaken an older T3 Code build's safety guarantees. + */ +export function filterMuseLaunchArgs(input: string | undefined): ReadonlyArray { + const tokens = tokenizeCliArgs(input); + const filtered: string[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]!; + const equalsIndex = token.indexOf("="); + const name = equalsIndex >= 0 ? token.slice(0, equalsIndex) : token; + const inlineValue = equalsIndex >= 0 ? token.slice(equalsIndex + 1) : undefined; + + if (MUSE_SAFE_BOOLEAN_LAUNCH_ARGS.has(name)) { + if (inlineValue === undefined) filtered.push(name); + continue; + } + + const validateValue = MUSE_SAFE_VALUE_LAUNCH_ARGS[name]; + if (!validateValue) continue; + + if (inlineValue !== undefined) { + if (validateValue(inlineValue)) filtered.push(name, inlineValue); + continue; + } + + const next = tokens[index + 1]; + if (next === undefined || next.startsWith("-")) continue; + index += 1; + if (validateValue(next)) filtered.push(name, next); + } + + return filtered; +} + +export interface MuseJsonEvent { + readonly schema_version: number; + readonly id: string; + readonly sequence: number; + readonly recorded_at: number; + readonly record_type: string; + readonly durability: string; + readonly causation_id?: string | null | undefined; + readonly payload_type: string; + readonly payload_schema_version: number; + readonly stream: { + readonly kind: string; + readonly id: string; + }; + readonly payload: Readonly>; +} + +export type MuseJsonLine = + | { readonly kind: "event"; readonly event: MuseJsonEvent } + | { readonly kind: "diagnostic"; readonly text: string }; + +export interface MuseTerminalRecord { + readonly terminal: string; + readonly text?: string | undefined; + readonly reason?: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function parseMuseJsonLine(line: string): MuseJsonLine { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return { kind: "diagnostic", text: "" }; + } + + const decoded = Option.getOrUndefined(decodeUnknownJson(trimmed)); + if (decoded === undefined) { + return { kind: "diagnostic", text: trimmed }; + } + + if (!isRecord(decoded)) { + return { kind: "diagnostic", text: trimmed }; + } + const stream = decoded.stream; + const payload = decoded.payload; + if ( + decoded.schema_version !== 1 || + typeof decoded.id !== "string" || + typeof decoded.sequence !== "number" || + typeof decoded.recorded_at !== "number" || + typeof decoded.record_type !== "string" || + typeof decoded.durability !== "string" || + typeof decoded.payload_type !== "string" || + typeof decoded.payload_schema_version !== "number" || + !isRecord(stream) || + typeof stream.kind !== "string" || + typeof stream.id !== "string" || + !isRecord(payload) + ) { + return { kind: "diagnostic", text: trimmed }; + } + + return { + kind: "event", + event: { + schema_version: decoded.schema_version, + id: decoded.id, + sequence: decoded.sequence, + recorded_at: decoded.recorded_at, + record_type: decoded.record_type, + durability: decoded.durability, + ...(typeof decoded.causation_id === "string" || decoded.causation_id === null + ? { causation_id: decoded.causation_id } + : {}), + payload_type: decoded.payload_type, + payload_schema_version: decoded.payload_schema_version, + stream: { + kind: stream.kind, + id: stream.id, + }, + payload, + }, + }; +} + +export function museEventText(event: MuseJsonEvent): string | undefined { + return typeof event.payload.text === "string" ? event.payload.text : undefined; +} + +export function museOutputDelta(event: MuseJsonEvent): string | undefined { + return event.payload_type === "run.output.delta" ? museEventText(event) : undefined; +} + +export function museReasoningDelta(event: MuseJsonEvent): string | undefined { + if (!event.payload_type.includes("reasoning") || !event.payload_type.endsWith(".delta")) { + return undefined; + } + return museEventText(event); +} + +export function musePlanDelta(event: MuseJsonEvent): string | undefined { + if (!event.payload_type.includes("plan") || !event.payload_type.endsWith(".delta")) { + return undefined; + } + return museEventText(event); +} + +export function museTerminalRecord(event: MuseJsonEvent): MuseTerminalRecord | undefined { + if (!event.payload_type.startsWith("run.terminal.")) { + return undefined; + } + const terminal = nonEmptyString(event.payload.terminal) ?? event.payload_type.slice(13); + const text = typeof event.payload.text === "string" ? event.payload.text : undefined; + const reason = nonEmptyString(event.payload.reason); + return { + terminal, + ...(text !== undefined ? { text } : {}), + ...(reason !== undefined ? { reason } : {}), + }; +} + +export function museTaskId(event: MuseJsonEvent): string | undefined { + return nonEmptyString(event.payload.task_id); +} + +export function museTaskLifecycle(event: MuseJsonEvent): + | { + readonly taskId: string; + readonly lifecycle: string; + readonly taskKind?: string | undefined; + readonly operation?: string | undefined; + readonly reason?: string | undefined; + } + | undefined { + if (!event.payload_type.startsWith("task.lifecycle.")) { + return undefined; + } + const taskId = museTaskId(event); + const nestedEvent = isRecord(event.payload.event) ? event.payload.event : undefined; + if (!taskId || !nestedEvent) { + return undefined; + } + return { + taskId, + lifecycle: event.payload_type.slice("task.lifecycle.".length), + ...(nonEmptyString(nestedEvent.task_kind) + ? { taskKind: nonEmptyString(nestedEvent.task_kind) } + : {}), + ...(nonEmptyString(nestedEvent.operation) + ? { operation: nonEmptyString(nestedEvent.operation) } + : {}), + ...(nonEmptyString(nestedEvent.reason) ? { reason: nonEmptyString(nestedEvent.reason) } : {}), + }; +} + +/** Internal model/reminder work is intentionally hidden from the work log. */ +export function isMuseUserVisibleTaskKind(taskKind: string): boolean { + const normalized = taskKind.toLowerCase(); + if (normalized.startsWith("model.") || normalized.startsWith("reminder.")) { + return false; + } + return ( + normalized.includes("tool") || + normalized.includes("shell") || + normalized.includes("command") || + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("web") || + normalized.includes("image") || + normalized.includes("agent") || + normalized.includes("workflow") + ); +} diff --git a/apps/server/src/provider/Layers/MuseProvider.test.ts b/apps/server/src/provider/Layers/MuseProvider.test.ts new file mode 100644 index 000000000000..e0a66072a175 --- /dev/null +++ b/apps/server/src/provider/Layers/MuseProvider.test.ts @@ -0,0 +1,445 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { MuseSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialMuseProviderSnapshot, + checkMuseProviderStatus, + hasStoredMuseCredential, + parseMuseCliVersion, + parseMuseSkillsListOutput, +} from "./MuseProvider.ts"; + +const decodeMuseSettings = Schema.decodeSync(MuseSettings); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +describe("buildInitialMuseProviderSnapshot", () => { + it.effect("publishes the built-in Muse model and reasoning controls", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialMuseProviderSnapshot(decodeMuseSettings({})); + expect(snapshot.displayName).toBe("Muse Code"); + expect(snapshot.badgeLabel).toBe("Beta"); + expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.models).toHaveLength(1); + expect(snapshot.models[0]).toMatchObject({ + slug: "muse-spark-1.2", + name: "Muse Spark 1.2", + isDefault: true, + }); + expect(snapshot.models[0]?.capabilities?.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "none", label: "None" }, + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra high" }, + { id: "ultra", label: "Ultra" }, + ], + currentValue: "high", + }, + ]); + }), + ); + + it.effect("returns a disabled snapshot when Muse is disabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialMuseProviderSnapshot( + decodeMuseSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); +}); + +describe("parseMuseCliVersion", () => { + it("prefers Meta's release build identifier", () => { + expect(parseMuseCliVersion("Muse Code 0.1.0 (0.1.0-R708.1)")).toBe("0.1.0-R708.1"); + }); +}); + +describe("parseMuseSkillsListOutput", () => { + it("maps Muse skill metadata and activation states", () => { + expect( + parseMuseSkillsListOutput( + encodeUnknownJson({ + diagnostics: [], + skills: [ + { + activation: "on", + description: "Create a grounded plan.", + display_name: "Plan", + name: "plan", + path: "bundled://muse-core/skills/plan/SKILL.md", + scope: "bundled", + short_description: "Plan before implementing", + }, + { + activation: "user-invocable-only", + name: "doctor", + path: "/config/muse/skills/doctor/SKILL.md", + scope: "user", + }, + { + activation: "off", + name: "disabled-skill", + path: "/workspace/.muse/skills/disabled-skill/SKILL.md", + scope: "project", + }, + { activation: "on", name: "missing-path" }, + ], + }), + ), + ).toEqual([ + { + name: "plan", + path: "bundled://muse-core/skills/plan/SKILL.md", + enabled: true, + description: "Create a grounded plan.", + scope: "bundled", + displayName: "Plan", + shortDescription: "Plan before implementing", + }, + { + name: "doctor", + path: "/config/muse/skills/doctor/SKILL.md", + enabled: true, + scope: "user", + }, + { + name: "disabled-skill", + path: "/workspace/.muse/skills/disabled-skill/SKILL.md", + enabled: false, + scope: "project", + }, + ]); + }); + + it("treats malformed output as an empty inventory", () => { + expect(parseMuseSkillsListOutput("not-json")).toEqual([]); + expect(parseMuseSkillsListOutput(encodeUnknownJson({ skills: "not-an-array" }))).toEqual([]); + }); +}); + +it.layer(NodeServices.layer)("checkMuseProviderStatus", (it) => { + it.effect("reports a missing Muse binary", () => + Effect.gen(function* () { + const snapshot = yield* checkMuseProviderStatus( + decodeMuseSettings({ binaryPath: "/definitely/not/installed/muse" }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("not installed"); + }), + ); + + it.effect("probes the full release version without allowing auto-update", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-version-" }); + const binaryPath = path.join(directory, "muse"); + yield* fs.writeFileString( + binaryPath, + [ + "#!/bin/sh", + '[ "$MUSE_NO_AUTO_UPDATE" = "1" ] || exit 9', + 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', + "", + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus( + decodeMuseSettings({ binaryPath, customModels: ["muse-spark-preview"] }), + { ...process.env, META_API_KEY: "test-key-not-logged" }, + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.1.0-R708.1"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "meta", + label: "Meta API key", + }); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "muse-spark-1.2", + "muse-spark-preview", + ]); + }), + ), + ); + + it.effect("discovers native skills with the instance binary, environment, and workspace", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-skills-" }); + const workspace = path.join(directory, "workspace"); + const binaryPath = path.join(directory, "muse"); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + binaryPath, + [ + "#!/bin/sh", + '[ "$MUSE_NO_AUTO_UPDATE" = "1" ] || exit 9', + '[ "$MUSE_INSTANCE_MARKER" = "instance-env" ] || exit 8', + 'if [ "$1" = "--version" ]; then', + ' printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', + " exit 0", + "fi", + '[ "$PWD" = "$MUSE_EXPECTED_WORKSPACE" ] || exit 7', + '[ "$#" -eq 8 ] || exit 6', + '[ "$1" = "skills" ] && [ "$2" = "list" ] || exit 5', + '[ "$3" = "--json" ] && [ "$4" = "--source" ] && [ "$5" = "all" ] || exit 4', + '[ "$6" = "--workspace" ] && [ "$7" = "$MUSE_EXPECTED_WORKSPACE" ] || exit 3', + '[ "$8" = "--trust-workspace" ] || exit 2', + `printf '%s\\n' '${encodeUnknownJson({ + diagnostics: [], + skills: [ + { + activation: "on", + description: "Import another coding-agent session.", + display_name: "Import", + name: "import", + path: "bundled://muse-core/skills/import/SKILL.md", + scope: "bundled", + short_description: "Import a prior session", + }, + ], + })}'`, + "", + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus( + decodeMuseSettings({ binaryPath }), + { + ...process.env, + META_API_KEY: "test-key-not-logged", + MUSE_EXPECTED_WORKSPACE: workspace, + MUSE_INSTANCE_MARKER: "instance-env", + }, + workspace, + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.skills).toEqual([ + { + name: "import", + path: "bundled://muse-core/skills/import/SKILL.md", + enabled: true, + description: "Import another coding-agent session.", + scope: "bundled", + displayName: "Import", + shortDescription: "Import a prior session", + }, + ]); + }), + ), + ); + + it.effect("reports configured Muse credentials without exposing their values", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-auth-" }); + const configHome = path.join(directory, "config"); + const authDirectory = path.join(configHome, "muse"); + const binaryPath = path.join(directory, "muse"); + yield* fs.makeDirectory(authDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(authDirectory, "auth.json"), + encodeUnknownJson({ + schema_version: 1, + providers: { meta: { oauth: { refresh_token: "stored-secret" } } }, + }), + ); + yield* fs.writeFileString( + binaryPath, + ["#!/bin/sh", 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', ""].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const environment = { ...process.env, XDG_CONFIG_HOME: configHome, META_API_KEY: "" }; + expect(yield* hasStoredMuseCredential(environment)).toBe(true); + const snapshot = yield* checkMuseProviderStatus( + decodeMuseSettings({ binaryPath }), + environment, + ); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "meta", + label: "Meta credentials", + }); + expect(encodeUnknownJson(snapshot)).not.toContain("stored-secret"); + }), + ), + ); + + it.effect("reports definitively absent Muse credentials as unauthenticated", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-no-auth-" }); + const configHome = path.join(directory, "config"); + const binaryPath = path.join(directory, "muse"); + yield* fs.makeDirectory(configHome, { recursive: true }); + yield* fs.writeFileString( + binaryPath, + ["#!/bin/sh", 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', ""].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus(decodeMuseSettings({ binaryPath }), { + ...process.env, + XDG_CONFIG_HOME: configHome, + META_API_KEY: "", + }); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth).toEqual({ status: "unauthenticated" }); + expect(snapshot.message).toContain("Use Connect"); + }), + ), + ); + + it.effect("preserves unknown auth when no credential root can be resolved", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-muse-auth-unknown-", + }); + const binaryPath = path.join(directory, "muse"); + yield* fs.writeFileString( + binaryPath, + ["#!/bin/sh", 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', ""].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus(decodeMuseSettings({ binaryPath }), { + HOME: "", + XDG_CONFIG_HOME: "", + META_API_KEY: "", + }); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth).toEqual({ status: "unknown" }); + }), + ), + ); + + it.effect("preserves unknown auth for an unfamiliar credential schema", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-auth-schema-" }); + const configHome = path.join(directory, "config"); + const authDirectory = path.join(configHome, "muse"); + const binaryPath = path.join(directory, "muse"); + yield* fs.makeDirectory(authDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(authDirectory, "auth.json"), + encodeUnknownJson({ schema_version: 2, providers: {} }), + ); + yield* fs.writeFileString( + binaryPath, + ["#!/bin/sh", 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', ""].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus(decodeMuseSettings({ binaryPath }), { + ...process.env, + XDG_CONFIG_HOME: configHome, + META_API_KEY: "", + }); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth).toEqual({ status: "unknown" }); + }), + ), + ); + + it.effect("preserves unknown auth when the credential record is unreadable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-auth-mode-" }); + const configHome = path.join(directory, "config"); + const authDirectory = path.join(configHome, "muse"); + const authPath = path.join(authDirectory, "auth.json"); + const binaryPath = path.join(directory, "muse"); + yield* fs.makeDirectory(authDirectory, { recursive: true }); + yield* fs.writeFileString( + authPath, + encodeUnknownJson({ + schema_version: 1, + providers: { meta: { api_key: "must-not-be-inspected" } }, + }), + ); + yield* fs.chmod(authPath, 0o000); + yield* fs.writeFileString( + binaryPath, + ["#!/bin/sh", 'printf "%s\\n" "Muse Code 0.1.0 (0.1.0-R708.1)"', ""].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + + const snapshot = yield* checkMuseProviderStatus(decodeMuseSettings({ binaryPath }), { + ...process.env, + XDG_CONFIG_HOME: configHome, + META_API_KEY: "", + }); + yield* fs.chmod(authPath, 0o600); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth).toEqual({ status: "unknown" }); + }), + ), + ); + + it.effect("does not treat an empty or malformed Muse auth file as authenticated", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-auth-empty-" }); + const configHome = path.join(directory, "config"); + const authDirectory = path.join(configHome, "muse"); + yield* fs.makeDirectory(authDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(authDirectory, "auth.json"), + encodeUnknownJson({ schema_version: 1, providers: {} }), + ); + + expect( + yield* hasStoredMuseCredential({ ...process.env, XDG_CONFIG_HOME: configHome }), + ).toBe(false); + yield* fs.writeFileString(path.join(authDirectory, "auth.json"), "not-json"); + expect( + yield* hasStoredMuseCredential({ ...process.env, XDG_CONFIG_HOME: configHome }), + ).toBe(false); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/MuseProvider.ts b/apps/server/src/provider/Layers/MuseProvider.ts new file mode 100644 index 000000000000..afe438f26976 --- /dev/null +++ b/apps/server/src/provider/Layers/MuseProvider.ts @@ -0,0 +1,398 @@ +import { + type ModelCapabilities, + type MuseSettings, + type ServerProviderModel, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const MUSE_PRESENTATION = { + displayName: "Muse Code", + badgeLabel: "Beta", + showInteractionModeToggle: true, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const SKILLS_PROBE_TIMEOUT_MS = 4_000; +const SKILLS_PROBE_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); +export const DEFAULT_MUSE_MODEL = "muse-spark-1.2"; + +export const MUSE_REASONING_EFFORTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "ultra", +] as const; + +export type MuseReasoningEffort = (typeof MUSE_REASONING_EFFORTS)[number]; + +export const MUSE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: MUSE_REASONING_EFFORTS.map((value) => ({ + value, + label: value === "xhigh" ? "Extra high" : value.charAt(0).toUpperCase() + value.slice(1), + ...(value === "high" ? { isDefault: true as const } : {}), + })), + }), + ], +}); + +const MUSE_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: DEFAULT_MUSE_MODEL, + name: "Muse Spark 1.2", + isDefault: true, + isCustom: false, + capabilities: MUSE_MODEL_CAPABILITIES, + }, +]; + +export function makeMuseEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...environment, + MUSE_NO_AUTO_UPDATE: "1", + }; +} + +export function parseMuseCliVersion(output: string): string | null { + return output.match(/\b(\d+\.\d+\.\d+-R\d+(?:\.\d+)?)\b/)?.[1] ?? parseGenericCliVersion(output); +} + +export function resolveMuseReasoningEffort(value: string | undefined): MuseReasoningEffort { + return MUSE_REASONING_EFFORTS.find((effort) => effort === value) ?? "high"; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Map Muse's native skills inventory into the provider snapshot contract. */ +export function parseMuseSkillsListOutput(output: string): ReadonlyArray { + const decoded = Option.getOrUndefined(decodeUnknownJson(output)); + if (!isRecord(decoded) || !Array.isArray(decoded.skills)) return []; + + const skills: Array = []; + for (const value of decoded.skills) { + if (!isRecord(value)) continue; + const name = nonEmptyString(value.name); + const skillPath = nonEmptyString(value.path); + if (!name || !skillPath) continue; + + const activation = nonEmptyString(value.activation); + const description = nonEmptyString(value.description); + const scope = nonEmptyString(value.scope); + const displayName = nonEmptyString(value.display_name); + const shortDescription = nonEmptyString(value.short_description); + skills.push({ + name, + path: skillPath, + enabled: activation === "on" || activation === "user-invocable-only", + ...(description ? { description } : {}), + ...(scope ? { scope } : {}), + ...(displayName ? { displayName } : {}), + ...(shortDescription ? { shortDescription } : {}), + }); + } + return skills; +} + +function hasCredentialValue(value: unknown): boolean { + if (!isRecord(value)) return false; + for (const key of ["api_key", "access_token", "refresh_token"] as const) { + if (typeof value[key] === "string" && value[key].trim().length > 0) { + return true; + } + } + return Object.values(value).some((nested) => isRecord(nested) && hasCredentialValue(nested)); +} + +type StoredMuseCredentialState = "configured" | "missing" | "unknown"; + +/** + * Muse does not expose an auth-status command. Inspect only the shape of its + * local credential record and never retain or surface any credential value. + * A missing credential is distinct from an unreadable or unfamiliar record: + * only the former is enough evidence to mark the provider unauthenticated. + */ +const storedMuseCredentialState = Effect.fn("storedMuseCredentialState")(function* ( + environment: NodeJS.ProcessEnv, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configHome = environment.XDG_CONFIG_HOME?.trim() + ? path.resolve(environment.XDG_CONFIG_HOME) + : environment.HOME?.trim() + ? path.join(path.resolve(environment.HOME), ".config") + : undefined; + if (!configHome) return "unknown" satisfies StoredMuseCredentialState; + + const authJson = yield* fileSystem + .readFileString(path.join(configHome, "muse", "auth.json")) + .pipe(Effect.result); + if (Result.isFailure(authJson)) { + return authJson.failure instanceof PlatformError.PlatformError && + authJson.failure.reason._tag === "NotFound" + ? ("missing" satisfies StoredMuseCredentialState) + : ("unknown" satisfies StoredMuseCredentialState); + } + + const decoded = Option.getOrUndefined(decodeUnknownJson(authJson.success)); + if (!isRecord(decoded) || decoded.schema_version !== 1 || !isRecord(decoded.providers)) { + return "unknown" satisfies StoredMuseCredentialState; + } + return hasCredentialValue(decoded.providers.meta) + ? ("configured" satisfies StoredMuseCredentialState) + : ("missing" satisfies StoredMuseCredentialState); +}); + +export const hasStoredMuseCredential = Effect.fn("hasStoredMuseCredential")(function* ( + environment: NodeJS.ProcessEnv, +) { + return (yield* storedMuseCredentialState(environment)) === "configured"; +}); + +function museModelsFromSettings( + customModels: ReadonlyArray, +): ReadonlyArray { + return providerModelsFromSettings(MUSE_BUILT_IN_MODELS, customModels, MUSE_MODEL_CAPABILITIES); +} + +export const buildInitialMuseProviderSnapshot = Effect.fn("buildInitialMuseProviderSnapshot")( + function* (museSettings: MuseSettings): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = museModelsFromSettings(museSettings.customModels); + + if (!museSettings.enabled) { + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Muse Code is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Muse Code availability...", + }, + }); + }, +); + +const runMuseVersionCommand = Effect.fn("runMuseVersionCommand")(function* ( + museSettings: MuseSettings, + environment: NodeJS.ProcessEnv, +) { + const command = museSettings.binaryPath || "muse"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +const discoverMuseSkills = Effect.fn("discoverMuseSkills")(function* ( + museSettings: MuseSettings, + environment: NodeJS.ProcessEnv, + cwd: string, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const command = museSettings.binaryPath || "muse"; + const args = [ + "skills", + "list", + "--json", + "--source", + "all", + "--workspace", + cwd, + "--trust-workspace", + ]; + const probe = yield* resolveSpawnCommand(command, args, { env: environment }).pipe( + Effect.flatMap((spawnCommand) => + spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd, + env: environment, + shell: spawnCommand.shell, + }), + { maxOutputBytes: SKILLS_PROBE_MAX_OUTPUT_BYTES }, + ), + ), + Effect.timeoutOption(SKILLS_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(probe) || Option.isNone(probe.success)) return []; + + const output = probe.success.value; + if (output.code !== 0 || output.stdoutTruncated) return []; + return parseMuseSkillsListOutput(output.stdout); +}); + +export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(function* ( + museSettings: MuseSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = museModelsFromSettings(museSettings.customModels); + + if (!museSettings.enabled) { + return yield* buildInitialMuseProviderSnapshot(museSettings); + } + + const resolvedEnvironment = makeMuseEnvironment(environment); + const versionResult = yield* runMuseVersionCommand(museSettings, resolvedEnvironment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "Muse Code CLI (`muse`) is not installed or not on PATH." + : "Failed to execute the Muse Code CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Muse Code is installed but timed out while running `muse --version`.", + }, + }); + } + + const output = versionResult.success.value; + const version = parseMuseCliVersion(`${output.stdout}\n${output.stderr}`); + if (output.code !== 0) { + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Muse Code is installed but failed to run.", + }, + }); + } + + const skills = yield* discoverMuseSkills(museSettings, resolvedEnvironment, cwd); + const hasApiKey = Boolean(resolvedEnvironment.META_API_KEY?.trim()); + const storedCredential = hasApiKey + ? ("missing" as const) + : yield* storedMuseCredentialState(resolvedEnvironment).pipe( + Effect.orElseSucceed(() => "unknown" as const), + ); + const isUnauthenticated = !hasApiKey && storedCredential === "missing"; + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models, + skills, + probe: { + installed: true, + version, + status: isUnauthenticated ? "error" : version ? "ready" : "warning", + auth: hasApiKey + ? { status: "authenticated", type: "meta", label: "Meta API key" } + : storedCredential === "configured" + ? { status: "authenticated", type: "meta", label: "Meta credentials" } + : storedCredential === "missing" + ? { status: "unauthenticated" } + : { status: "unknown" }, + ...(isUnauthenticated + ? { + message: + "Muse Code is not authenticated. Use Connect to sign in with Meta or enter a Meta API key.", + } + : version + ? {} + : { message: "Muse Code is installed but its version could not be read." }), + }, + }); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabec..9040f40432cf 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -233,6 +233,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + upsertIfActive: () => Effect.succeed(true), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 000000000000..4443a357dd9f --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { resolveBuiltInDrivers } from "../builtInDrivers.ts"; +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; + +describe("ProviderInstanceRegistryHydration runtime gates", () => { + const museDriverKind = ProviderDriverKind.make("muse"); + const museDefaultId = ProviderInstanceId.make("muse"); + const productionDrivers = resolveBuiltInDrivers({ museCodeEnabled: false }); + + it("does not synthesize the legacy Muse instance when Muse is withheld", () => { + const configMap = deriveProviderInstanceConfigMap(DEFAULT_SERVER_SETTINGS, productionDrivers); + + expect(configMap[museDefaultId]).toBeUndefined(); + expect(productionDrivers.some((driver) => driver.driverKind === museDriverKind)).toBe(false); + }); + + it("retains stale explicit Muse settings without adding an executable Muse driver", () => { + const customId = ProviderInstanceId.make("muse_internal"); + const explicitMuse = { + driver: museDriverKind, + enabled: true, + config: { binaryPath: "muse" }, + } as const; + const configMap = deriveProviderInstanceConfigMap( + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { [customId]: explicitMuse }, + }, + productionDrivers, + ); + + expect(configMap[customId]).toEqual(explicitMuse); + expect(configMap[museDefaultId]).toBeUndefined(); + expect(productionDrivers.some((driver) => driver.driverKind === museDriverKind)).toBe(false); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0fd88b4262a6..d960af5c66dc 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -51,8 +51,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; +import { + BUILT_IN_DRIVERS, + resolveBuiltInDrivers, + type BuiltInDriversEnv, +} from "../builtInDrivers.ts"; +import type { AnyProviderDriver } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderInstanceRegistryMutator } from "../Services/ProviderInstanceRegistryMutator.ts"; import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistryLive.ts"; @@ -72,10 +78,11 @@ import { ProviderInstanceRegistryMutableLayer } from "./ProviderInstanceRegistry */ export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, + drivers: ReadonlyArray> = BUILT_IN_DRIVERS, ): ProviderInstanceConfigMap => { const merged: Record = { ...settings.providerInstances }; - for (const driver of BUILT_IN_DRIVERS) { + for (const driver of drivers) { const instanceId = defaultInstanceIdForDriver(driver.driverKind); if (instanceId in merged) { // Explicit `providerInstances` entry for this slot — user-authored @@ -114,24 +121,25 @@ export const deriveProviderInstanceConfigMap = ( * configs, so the only way the watcher could fail is a settings stream * tear-down, which logs and exits cleanly. */ -const SettingsWatcherLive = Layer.effectDiscard( - Effect.gen(function* () { - const mutator = yield* ProviderInstanceRegistryMutator; - const serverSettings = yield* ServerSettingsService; - yield* serverSettings.streamChanges.pipe( - Stream.runForEach((next) => - mutator - .reconcile(deriveProviderInstanceConfigMap(next)) - .pipe( - Effect.catchCause((cause) => - Effect.logError("ProviderInstanceRegistry reconcile failed", cause), +const makeSettingsWatcherLive = (drivers: ReadonlyArray>) => + Layer.effectDiscard( + Effect.gen(function* () { + const mutator = yield* ProviderInstanceRegistryMutator; + const serverSettings = yield* ServerSettingsService; + yield* serverSettings.streamChanges.pipe( + Stream.runForEach((next) => + mutator + .reconcile(deriveProviderInstanceConfigMap(next, drivers)) + .pipe( + Effect.catchCause((cause) => + Effect.logError("ProviderInstanceRegistry reconcile failed", cause), + ), ), - ), - ), - Effect.forkScoped, - ); - }), -); + ), + Effect.forkScoped, + ); + }), + ); /** * Hydrate `ProviderInstanceRegistry` from `ServerSettings` and keep it in @@ -141,7 +149,7 @@ const SettingsWatcherLive = Layer.effectDiscard( * - `ProviderInstanceRegistryMutableLayer` produces the registry + * mutator from the initial config map. Its scope owns every * per-instance child scope created during reconcile. - * - `SettingsWatcherLive` consumes the mutator and runs a daemon fiber + * - `makeSettingsWatcherLive` consumes the mutator and runs a daemon fiber * in the same scope. * * Composing via `Layer.provideMerge` makes the watcher's deps available @@ -152,23 +160,31 @@ const SettingsWatcherLive = Layer.effectDiscard( export const ProviderInstanceRegistryHydrationLive: Layer.Layer< ProviderInstanceRegistry, never, - BuiltInDriversEnv | ServerSettingsService + BuiltInDriversEnv | ServerConfig | ServerSettingsService > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const drivers = resolveBuiltInDrivers({ + museCodeEnabled: serverConfig.museCodeEnabled, + }); const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => undefined), ); const initialConfigMap = initialSettings === undefined ? ({} as ProviderInstanceConfigMap) - : deriveProviderInstanceConfigMap(initialSettings); + : deriveProviderInstanceConfigMap(initialSettings, drivers); const mutableLayer = ProviderInstanceRegistryMutableLayer({ - drivers: BUILT_IN_DRIVERS, + drivers, configMap: initialConfigMap, }); - return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); + return makeSettingsWatcherLive(drivers).pipe(Layer.provideMerge(mutableLayer)); }), -) as Layer.Layer; +) as Layer.Layer< + ProviderInstanceRegistry, + never, + BuiltInDriversEnv | ServerConfig | ServerSettingsService +>; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 384de852f9b5..3489f481cd8f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `muse`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,8 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `muse` / + * `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -29,6 +30,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type MuseSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -44,6 +46,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; +import { MuseDriver } from "../Drivers/MuseDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -90,6 +93,14 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeMuseConfig = (overrides: Partial): MuseSettings => ({ + enabled: false, + binaryPath: "muse", + launchArgs: "", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -258,12 +269,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); + const museId = ProviderInstanceId.make("muse_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); + const museDriverKind = ProviderDriverKind.make("muse"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -294,6 +307,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [museId]: { + driver: museDriverKind, + displayName: "Muse Code", + enabled: false, + config: makeMuseConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -303,7 +322,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, MuseDriver, OpenCodeDriver], configMap, }); @@ -313,9 +332,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, museId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -325,16 +344,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); + const muse = yield* registry.getInstance(museId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); + expect(muse?.driverKind).toBe(museDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); + expect(muse?.displayName).toBe("Muse Code"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -347,6 +369,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + muse!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -355,6 +378,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + muse!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -363,6 +387,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + muse!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -399,6 +424,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const museSnapshot = yield* muse!.snapshot.getSnapshot; + expect(museSnapshot.instanceId).toBe(museId); + expect(museSnapshot.driver).toBe(museDriverKind); + expect(museSnapshot.enabled).toBe(false); + expect(museSnapshot.continuation?.groupKey).toBe(`${museDriverKind}:instance:${museId}`); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 903865079956..78e43e78d055 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1454,6 +1454,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + muse: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1565,6 +1566,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + muse: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1815,6 +1817,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "muse", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f0..21bd427ef4f8 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -22,6 +22,7 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -1020,6 +1021,215 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not let a late send completion overwrite a restarted binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sendStarted = yield* Deferred.make(); + const releaseSend = yield* Deferred.make(); + const threadId = asThreadId("thread-send-stop-race"); + const originalSendTurn = routing.codex.sendTurn.getMockImplementation(); + + const initial = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-send-stop-race", + runtimeMode: "full-access", + }); + routing.codex.sendTurn.mockImplementation((input) => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(releaseSend); + return { + threadId: input.threadId, + turnId: asTurnId("late-turn"), + resumeCursor: { opaque: "late-resume" }, + }; + }), + ); + + const sendFiber = yield* provider + .sendTurn({ threadId, input: "race stop", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sendStarted); + yield* provider.stopSession({ threadId }); + + const stopped = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(stopped), true); + let stoppedGeneration: unknown; + if (Option.isSome(stopped)) { + assert.equal(stopped.value.status, "stopped"); + assert.deepEqual(stopped.value.resumeCursor, initial.resumeCursor); + const payload = stopped.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as Record; + stoppedGeneration = runtimePayload.sessionGeneration; + assert.equal(typeof stoppedGeneration, "string"); + assert.equal(runtimePayload.activeTurnId, null); + } + } + + const restarted = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-send-stop-race", + resumeCursor: { opaque: "restart-resume" }, + runtimeMode: "full-access", + }); + const restartedBeforeLateSend = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(restartedBeforeLateSend), true); + let restartedGeneration: unknown; + if (Option.isSome(restartedBeforeLateSend)) { + const payload = restartedBeforeLateSend.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as Record; + restartedGeneration = runtimePayload.sessionGeneration; + assert.equal(typeof restartedGeneration, "string"); + assert.notEqual(restartedGeneration, stoppedGeneration); + } + } + + yield* Deferred.succeed(releaseSend, undefined); + const sendExit = yield* Fiber.await(sendFiber); + if (originalSendTurn) { + routing.codex.sendTurn.mockImplementation(originalSendTurn); + } + assert.equal(Exit.isSuccess(sendExit), true); + + const persisted = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.status, "running"); + assert.deepEqual(persisted.value.resumeCursor, restarted.resumeCursor); + 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 Record; + assert.equal(runtimePayload.sessionGeneration, restartedGeneration); + assert.equal(runtimePayload.activeTurnId, null); + assert.equal(runtimePayload.lastRuntimeEvent, undefined); + } + } + }), + ); + + it.effect("does not recover from a stale binding after a concurrent restart", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const hasSessionChecked = yield* Deferred.make(); + const releaseHasSession = yield* Deferred.make(); + const threadId = asThreadId("thread-recovery-restart-race"); + const originalHasSession = routing.codex.hasSession.getMockImplementation(); + + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-recovery-restart-race", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.hasSession.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(hasSessionChecked, undefined); + yield* Deferred.await(releaseHasSession); + return false; + }), + ); + + const sendFiber = yield* provider + .sendTurn({ threadId, input: "stale recovery", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(hasSessionChecked); + const restarted = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-recovery-restart-race", + resumeCursor: { opaque: "new-generation" }, + runtimeMode: "full-access", + }); + const beforeLateRecovery = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(beforeLateRecovery), true); + const expectedGeneration = + Option.isSome(beforeLateRecovery) && + beforeLateRecovery.value.runtimePayload !== null && + typeof beforeLateRecovery.value.runtimePayload === "object" && + !Array.isArray(beforeLateRecovery.value.runtimePayload) + ? (beforeLateRecovery.value.runtimePayload as Record).sessionGeneration + : undefined; + assert.equal(typeof expectedGeneration, "string"); + + yield* Deferred.succeed(releaseHasSession, undefined); + const sendExit = yield* Fiber.await(sendFiber); + if (originalHasSession) { + routing.codex.hasSession.mockImplementation(originalHasSession); + } + assert.equal(Exit.isFailure(sendExit), true); + + const persisted = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.status, "running"); + assert.deepEqual(persisted.value.resumeCursor, restarted.resumeCursor); + 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 Record; + assert.equal(runtimePayload.sessionGeneration, expectedGeneration); + assert.equal(runtimePayload.activeTurnId, null); + } + } + }), + ); + + it.effect("persists a stopped binding when service stop is interrupted", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const stopStarted = yield* Deferred.make(); + const threadId = asThreadId("thread-interrupted-service-stop"); + const originalStopSession = routing.codex.stopSession.getMockImplementation(); + + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-interrupted-service-stop", + runtimeMode: "full-access", + }); + routing.codex.stopSession.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(stopStarted, undefined); + return yield* Effect.never; + }), + ); + + const stopFiber = yield* provider.stopSession({ threadId }).pipe(Effect.forkChild); + yield* Deferred.await(stopStarted); + yield* Fiber.interrupt(stopFiber); + if (originalStopSession) { + routing.codex.stopSession.mockImplementation(originalStopSession); + } + + const persisted = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.status, "stopped"); + const payload = persisted.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + assert.equal((payload as Record).activeTurnId, null); + } + } + }), + ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index da328be7d996..fa24efe5e34a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -33,7 +33,9 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { increment, @@ -125,6 +127,7 @@ function toRuntimePayloadFromSession( readonly modelSelection?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly sessionGeneration?: string; }, ): Record { return { @@ -137,9 +140,24 @@ function toRuntimePayloadFromSession( ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } : {}), + ...(extra?.sessionGeneration !== undefined + ? { sessionGeneration: extra.sessionGeneration } + : {}), }; } +function readPersistedSessionGeneration( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): string | null { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return null; + } + const raw = "sessionGeneration" in runtimePayload ? runtimePayload.sessionGeneration : undefined; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + function readPersistedModelSelection( runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], ): ModelSelection | undefined { @@ -213,7 +231,29 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); + const lifecycleLocksRef = yield* SynchronizedRef.make(new Map()); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const sessionGenerationPrefix = DateTime.formatIso(yield* DateTime.now); + let sessionGenerationSequence = 0; + const nextSessionGeneration = () => + `${sessionGenerationPrefix}:${String(++sessionGenerationSequence)}`; + const getLifecycleLock = (threadId: string) => + SynchronizedRef.modifyEffect(lifecycleLocksRef, (current) => { + const existing = Option.fromNullishOr(current.get(threadId)); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + const withLifecycleLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getLifecycleLock(threadId), (semaphore) => semaphore.withPermit(effect)); const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => McpSessionRegistry.issueActiveMcpCredential({ threadId, @@ -267,6 +307,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly modelSelection?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; + readonly sessionGeneration?: string; }, ) => Effect.gen(function* () { @@ -356,7 +397,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( () => reconcileInstanceSubscriptions, ).pipe(Effect.forkScoped); - const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { + const recoverSessionForThreadUnlocked = Effect.fn("recoverSessionForThread")(function* (input: { readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding; readonly operation: string; }) { @@ -378,16 +419,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (session) => session.threadId === input.binding.threadId, ); if (existing) { + const sessionGeneration = + readPersistedSessionGeneration(input.binding.runtimePayload) ?? nextSessionGeneration(); yield* upsertSessionBinding( { ...existing, providerInstanceId: bindingInstanceId }, input.binding.threadId, + { sessionGeneration }, ); yield* analytics.record("provider.session.recovered", { provider: existing.provider, strategy: "adopt-existing", hasResumeCursor: existing.resumeCursor !== undefined, }); - return { adapter, session: existing } as const; + return { adapter, session: existing, sessionGeneration } as const; } } @@ -421,16 +465,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } + const sessionGeneration = nextSessionGeneration(); + yield* upsertSessionBinding( { ...resumed, providerInstanceId: bindingInstanceId }, input.binding.threadId, + { sessionGeneration }, ); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", hasResumeCursor: resumed.resumeCursor !== undefined, }); - return { adapter, session: resumed } as const; + return { adapter, session: resumed, sessionGeneration } as const; }).pipe( withMetrics({ counter: providerSessionsTotal, @@ -440,6 +487,34 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), ); }); + const recoverSessionForThread = (input: Parameters[0]) => + withLifecycleLock( + input.binding.threadId, + Effect.gen(function* () { + const current = Option.getOrUndefined(yield* directory.getBinding(input.binding.threadId)); + if (!current) { + return yield* toValidationError( + input.operation, + `Cannot recover thread '${input.binding.threadId}' because its provider binding no longer exists.`, + ); + } + const expectedInstanceId = yield* requireBindingInstanceId(input.operation, input.binding); + const currentInstanceId = yield* requireBindingInstanceId(input.operation, current); + const bindingChanged = + current.provider !== input.binding.provider || + currentInstanceId !== expectedInstanceId || + current.status !== input.binding.status || + readPersistedSessionGeneration(current.runtimePayload) !== + readPersistedSessionGeneration(input.binding.runtimePayload); + if (bindingChanged) { + return yield* toValidationError( + input.operation, + `Cannot recover thread '${input.binding.threadId}' because its provider session changed while the operation was being routed. Retry the operation.`, + ); + } + return yield* recoverSessionForThreadUnlocked({ ...input, binding: current }); + }), + ); const resolveRoutableSession = Effect.fn("resolveRoutableSession")(function* (input: { readonly threadId: ThreadId; @@ -464,6 +539,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( instanceId, threadId: input.threadId, isActive: true, + sessionGeneration: readPersistedSessionGeneration(binding.runtimePayload), } as const; } @@ -473,6 +549,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( instanceId, threadId: input.threadId, isActive: false, + sessionGeneration: readPersistedSessionGeneration(binding.runtimePayload), } as const; } @@ -485,6 +562,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( instanceId, threadId: input.threadId, isActive: true, + sessionGeneration: recovered.sessionGeneration, } as const; }); @@ -542,106 +620,113 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": threadId, "provider.runtime_mode": parsed.runtimeMode, }); - return yield* Effect.gen(function* () { - const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); - const resolvedProvider = instanceInfo.driverKind; - metricProvider = resolvedProvider; - if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, - ); - } - const input = { - ...parsed, - threadId, - provider: resolvedProvider, - }; - if (!instanceInfo.enabled) { - return yield* toValidationError( - "ProviderService.startSession", - `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, - ); - } - const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); - const effectiveCwd = - input.cwd ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? readPersistedCwd(persistedBinding.runtimePayload) - : undefined); - yield* Effect.annotateCurrentSpan({ - "provider.kind": resolvedProvider, - "provider.resume_cursor.source": - input.resumeCursor !== undefined - ? "request" - : effectiveResumeCursor !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId - ? "persisted" - : "none", - "provider.resume_cursor.present": effectiveResumeCursor !== undefined, - "provider.cwd.source": - input.cwd !== undefined - ? "request" - : effectiveCwd !== undefined && - persistedBinding?.providerInstanceId === resolvedInstanceId - ? "persisted" - : "none", - "provider.cwd.effective": effectiveCwd ?? "", - }); - const adapter = yield* registry.getByInstance(resolvedInstanceId); - yield* prepareMcpSession(threadId, resolvedInstanceId); - const session = yield* adapter - .startSession({ - ...input, - providerInstanceId: resolvedInstanceId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), - }) - .pipe(Effect.onError(() => clearMcpSession(threadId))); + return yield* withLifecycleLock( + threadId, + Effect.gen(function* () { + const instanceInfo = yield* registry.getInstanceInfo(resolvedInstanceId); + const resolvedProvider = instanceInfo.driverKind; + metricProvider = resolvedProvider; + if (parsed.provider !== undefined && parsed.provider !== resolvedProvider) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' belongs to driver '${resolvedProvider}', not '${parsed.provider}'.`, + ); + } + const input = { + ...parsed, + threadId, + provider: resolvedProvider, + }; + if (!instanceInfo.enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, + ); + } + const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const effectiveResumeCursor = + input.resumeCursor ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? persistedBinding.resumeCursor + : undefined); + const effectiveCwd = + input.cwd ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? readPersistedCwd(persistedBinding.runtimePayload) + : undefined); + yield* Effect.annotateCurrentSpan({ + "provider.kind": resolvedProvider, + "provider.resume_cursor.source": + input.resumeCursor !== undefined + ? "request" + : effectiveResumeCursor !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.resume_cursor.present": effectiveResumeCursor !== undefined, + "provider.cwd.source": + input.cwd !== undefined + ? "request" + : effectiveCwd !== undefined && + persistedBinding?.providerInstanceId === resolvedInstanceId + ? "persisted" + : "none", + "provider.cwd.effective": effectiveCwd ?? "", + }); + const adapter = yield* registry.getByInstance(resolvedInstanceId); + yield* prepareMcpSession(threadId, resolvedInstanceId); + const session = yield* adapter + .startSession({ + ...input, + providerInstanceId: resolvedInstanceId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveResumeCursor !== undefined + ? { resumeCursor: effectiveResumeCursor } + : {}), + }) + .pipe(Effect.onError(() => clearMcpSession(threadId))); - if (session.provider !== adapter.provider) { - yield* clearMcpSession(threadId); - return yield* toValidationError( - "ProviderService.startSession", - `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, - ); - } - const sessionWithInstance = { - ...session, - providerInstanceId: resolvedInstanceId, - }; + if (session.provider !== adapter.provider) { + yield* clearMcpSession(threadId); + return yield* toValidationError( + "ProviderService.startSession", + `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, + ); + } + const sessionWithInstance = { + ...session, + providerInstanceId: resolvedInstanceId, + }; - yield* stopStaleSessionsForThread({ - threadId, - currentInstanceId: resolvedInstanceId, - }); - yield* upsertSessionBinding(sessionWithInstance, threadId, { - modelSelection: input.modelSelection, - }); - yield* analytics.record("provider.session.started", { - provider: sessionWithInstance.provider, - runtimeMode: input.runtimeMode, - hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, - hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, - hasModel: - typeof input.modelSelection?.model === "string" && - input.modelSelection.model.trim().length > 0, - }); + yield* stopStaleSessionsForThread({ + threadId, + currentInstanceId: resolvedInstanceId, + }); + const sessionGeneration = nextSessionGeneration(); + yield* upsertSessionBinding(sessionWithInstance, threadId, { + modelSelection: input.modelSelection, + sessionGeneration, + }); + yield* analytics.record("provider.session.started", { + provider: sessionWithInstance.provider, + runtimeMode: input.runtimeMode, + hasResumeCursor: sessionWithInstance.resumeCursor !== undefined, + hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, + hasModel: + typeof input.modelSelection?.model === "string" && + input.modelSelection.model.trim().length > 0, + }); - return sessionWithInstance; - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - attributes: () => - providerMetricAttributes(metricProvider, { - operation: "start", - }), - }), + return sessionWithInstance; + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + attributes: () => + providerMetricAttributes(metricProvider, { + operation: "start", + }), + }), + ), ); }, ); @@ -684,19 +769,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(input.modelSelection?.model ? { "provider.model": input.modelSelection.model } : {}), }); const turn = yield* routed.adapter.sendTurn(input); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "running", - ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), - runtimePayload: { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - activeTurnId: turn.turnId, - lastRuntimeEvent: "provider.sendTurn", - lastRuntimeEventAt: yield* nowIso, + yield* directory.upsertIfActive( + { + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "running", + ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), + runtimePayload: { + ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + activeTurnId: turn.turnId, + lastRuntimeEvent: "provider.sendTurn", + lastRuntimeEventAt: yield* nowIso, + }, }, - }); + { sessionGeneration: routed.sessionGeneration }, + ); yield* analytics.record("provider.turn.sent", { provider: routed.adapter.provider, model: input.modelSelection?.model, @@ -838,42 +926,54 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( payload: rawInput, }); let metricProvider = "unknown"; - return yield* Effect.gen(function* () { - const routed = yield* resolveRoutableSession({ - threadId: input.threadId, - operation: "ProviderService.stopSession", - allowRecovery: false, - }); - metricProvider = routed.adapter.provider; - yield* Effect.annotateCurrentSpan({ - "provider.operation": "stop-session", - "provider.kind": routed.adapter.provider, - "provider.thread_id": input.threadId, - }); - if (routed.isActive) { - yield* routed.adapter.stopSession(routed.threadId); - } - yield* clearMcpSession(input.threadId); - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - providerInstanceId: routed.instanceId, - status: "stopped", - runtimePayload: { - activeTurnId: null, - }, - }); - yield* analytics.record("provider.session.stopped", { - provider: routed.adapter.provider, - }); - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - outcomeAttributes: () => - providerMetricAttributes(metricProvider, { - operation: "stop", + return yield* withLifecycleLock( + input.threadId, + Effect.gen(function* () { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.stopSession", + allowRecovery: false, + }); + metricProvider = routed.adapter.provider; + yield* Effect.annotateCurrentSpan({ + "provider.operation": "stop-session", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const stopExit = yield* Effect.exit( + restore( + routed.isActive ? routed.adapter.stopSession(routed.threadId) : Effect.void, + ), + ); + yield* clearMcpSession(input.threadId); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + providerInstanceId: routed.instanceId, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); + if (stopExit._tag === "Failure") { + return yield* Effect.failCause(stopExit.cause); + } }), - }), + ); + yield* analytics.record("provider.session.stopped", { + provider: routed.adapter.provider, + }); + }).pipe( + withMetrics({ + counter: providerSessionsTotal, + outcomeAttributes: () => + providerMetricAttributes(metricProvider, { + operation: "stop", + }), + }), + ), ); }, ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebfd..1956cbcd5fc6 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,7 +4,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertSome } from "@effect/vitest/utils"; import * as Effect from "effect/Effect"; @@ -122,6 +122,86 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("rejects late conditional writes after stop while allowing an explicit reopen", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-conditional-stop-race"); + const instanceId = ProviderInstanceId.make("codex"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "running", + resumeCursor: { cursor: "before-stop" }, + runtimePayload: { sessionGeneration: "generation-1" }, + }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "stopped", + runtimePayload: { activeTurnId: null }, + }); + + const lateWriteApplied = yield* directory.upsertIfActive( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "running", + resumeCursor: { cursor: "too-late" }, + runtimePayload: { activeTurnId: "late-turn" }, + }, + { sessionGeneration: "generation-1" }, + ); + assert.equal(lateWriteApplied, false); + + const stopped = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(stopped), true); + if (Option.isSome(stopped)) { + assert.equal(stopped.value.status, "stopped"); + assert.deepEqual(stopped.value.resumeCursor, { cursor: "before-stop" }); + assert.deepEqual(stopped.value.runtimePayload, { + sessionGeneration: "generation-1", + activeTurnId: null, + }); + } + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "running", + resumeCursor: { cursor: "explicit-restart" }, + runtimePayload: { sessionGeneration: "generation-2", activeTurnId: null }, + }); + const staleGenerationWriteApplied = yield* directory.upsertIfActive( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "running", + runtimePayload: { activeTurnId: "stale-turn" }, + }, + { sessionGeneration: "generation-1" }, + ); + assert.equal(staleGenerationWriteApplied, false); + + const activeWriteApplied = yield* directory.upsertIfActive( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + threadId, + status: "running", + runtimePayload: { activeTurnId: "fresh-turn" }, + }, + { sessionGeneration: "generation-2" }, + ); + assert.equal(activeWriteApplied, true); + })); + it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a06e..b769a5d50320 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -4,6 +4,7 @@ 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 Semaphore from "effect/Semaphore"; import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; @@ -57,6 +58,14 @@ function mergeRuntimePayload( return next; } +function readSessionGeneration(runtimePayload: unknown | null): string | null { + if (!isRecord(runtimePayload)) return null; + const generation = runtimePayload.sessionGeneration; + if (typeof generation !== "string") return null; + const trimmed = generation.trim(); + return trimmed.length > 0 ? trimmed : null; +} + function toRuntimeBinding( runtime: ProviderSessionRuntime.ProviderSessionRuntime, operation: string, @@ -85,6 +94,7 @@ function toRuntimeBinding( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const writeLock = yield* Semaphore.make(1); const getBinding = (threadId: ThreadId) => repository.getByThreadId({ threadId }).pipe( @@ -100,7 +110,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const upsertUnlocked: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { const existing = yield* repository .getByThreadId({ threadId: binding.threadId }) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); @@ -148,6 +158,51 @@ const makeProviderSessionDirectory = Effect.gen(function* () { .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); + const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => + writeLock.withPermit(upsertUnlocked(binding)); + + const upsertIfActive: ProviderSessionDirectoryShape["upsertIfActive"] = (binding, expected) => + writeLock.withPermit( + Effect.gen(function* () { + const existing = yield* repository + .getByThreadId({ threadId: binding.threadId }) + .pipe( + Effect.mapError( + toPersistenceError("ProviderSessionDirectory.upsertIfActive:getByThreadId"), + ), + ); + const current = Option.getOrUndefined(existing); + const expectedInstanceId = binding.providerInstanceId; + if (expectedInstanceId === undefined) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsertIfActive", + issue: "providerInstanceId is required for conditional provider session updates.", + }); + } + const currentInstanceId = current + ? (current.providerInstanceId ?? + defaultInstanceIdForDriver( + yield* decodeProviderDriverKind( + current.providerName, + "ProviderSessionDirectory.upsertIfActive", + ), + )) + : undefined; + if ( + current === undefined || + current.status === "stopped" || + current.providerName !== binding.provider || + currentInstanceId !== expectedInstanceId || + readSessionGeneration(current.runtimePayload) !== expected.sessionGeneration + ) { + return false; + } + + yield* upsertUnlocked(binding); + return true; + }), + ); + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => getBinding(threadId).pipe( Effect.flatMap((binding) => @@ -184,6 +239,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + upsertIfActive, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Services/MuseAdapter.ts b/apps/server/src/provider/Services/MuseAdapter.ts new file mode 100644 index 000000000000..d6afc55944d6 --- /dev/null +++ b/apps/server/src/provider/Services/MuseAdapter.ts @@ -0,0 +1,13 @@ +/** + * MuseAdapter — shape type for the Muse Code provider adapter. + * + * Muse instances are created by {@link ../Drivers/MuseDriver} and retain + * their adapter as a captured closure. This module is the public naming + * anchor for that per-instance shape. + * + * @module MuseAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface MuseAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..ea8520a332e0 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -45,6 +45,16 @@ export interface ProviderSessionDirectoryShape { binding: ProviderRuntimeBinding, ) => Effect.Effect; + /** + * Persist turn metadata only while the routed session is still active and + * owned by the same provider instance and session generation. Returns false + * when a concurrent stop or replacement won the race. + */ + readonly upsertIfActive: ( + binding: ProviderRuntimeBinding, + expected: { readonly sessionGeneration: string | null }, + ) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..e4b1461b13b6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | MuseDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,22 @@ export const BUILT_IN_DRIVERS: ReadonlyArray> { + return input.museCodeEnabled + ? BUILT_IN_DRIVERS + : BUILT_IN_DRIVERS.filter((driver) => driver !== MuseDriver); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 17235aca3f45..0ce780f199c6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -389,6 +389,7 @@ const buildAppUnderTest = (options?: { tailscaleServePort: 443, ...options?.config, managedDevPc: options?.config?.managedDevPc ?? false, + museCodeEnabled: options?.config?.museCodeEnabled ?? true, }; const layerConfig = ServerConfig.layer(config); const defaultVcsDriver: VcsDriver.VcsDriver["Service"] = { diff --git a/apps/server/src/textGeneration/MuseTextGeneration.test.ts b/apps/server/src/textGeneration/MuseTextGeneration.test.ts new file mode 100644 index 000000000000..11e127c2edb7 --- /dev/null +++ b/apps/server/src/textGeneration/MuseTextGeneration.test.ts @@ -0,0 +1,251 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { MuseSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as TextGeneration from "./TextGeneration.ts"; +import { makeMuseTextGeneration, parseMuseHeadlessOutput } from "./MuseTextGeneration.ts"; + +const decodeMuseSettings = Schema.decodeSync(MuseSettings); + +function museEvent(payloadType: string, payload: Record, sequence = 1): string { + return JSON.stringify({ + schema_version: 1, + id: `event-${sequence}`, + stream: { kind: "session", id: "text-generation-session" }, + sequence, + recorded_at: 1_780_531_400_000_000 + sequence, + record_type: "event", + durability: "durable", + payload_type: payloadType, + payload_schema_version: 1, + payload, + }); +} + +function museTerminalEvent(text: string, terminal = "completed"): string { + return museEvent(`run.terminal.${terminal}`, { + kind: "run_terminal", + terminal, + text, + }); +} + +function makeFakeMuseBinary(directory: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binaryPath = path.join(directory, "muse"); + yield* fs.writeFileString( + binaryPath, + [ + "#!/bin/sh", + '[ "$MUSE_NO_AUTO_UPDATE" = "1" ] || { printf "%s\\n" "auto update enabled" >&2; exit 8; }', + ': > "$T3_FAKE_MUSE_ARGS_FILE"', + 'for arg in "$@"; do', + ' printf "%s\\0" "$arg" >> "$T3_FAKE_MUSE_ARGS_FILE"', + "done", + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "--prompt-file" ]; then', + " shift", + ' cp "$1" "$T3_FAKE_MUSE_PROMPT_FILE"', + " break", + " fi", + " shift", + "done", + 'if [ -n "$T3_FAKE_MUSE_STDERR" ]; then printf "%s\\n" "$T3_FAKE_MUSE_STDERR" >&2; fi', + 'printf "%s" "$T3_FAKE_MUSE_OUTPUT"', + 'exit "${T3_FAKE_MUSE_EXIT_CODE:-0}"', + "", + ].join("\n"), + ); + yield* fs.chmod(binaryPath, 0o755); + return binaryPath; + }); +} + +function withFakeMuse( + input: { + readonly output: string; + readonly launchArgs?: string; + readonly exitCode?: number; + readonly stderr?: string; + }, + use: ( + textGeneration: TextGeneration.TextGeneration["Service"], + capture: { readonly argsFile: string; readonly promptFile: string }, + ) => Effect.Effect, +) { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-muse-text-" }); + const binaryPath = yield* makeFakeMuseBinary(directory); + const argsFile = path.join(directory, "args"); + const promptFile = path.join(directory, "prompt"); + const environment: NodeJS.ProcessEnv = { + ...process.env, + T3_FAKE_MUSE_ARGS_FILE: argsFile, + T3_FAKE_MUSE_PROMPT_FILE: promptFile, + T3_FAKE_MUSE_OUTPUT: input.output, + ...(input.exitCode === undefined ? {} : { T3_FAKE_MUSE_EXIT_CODE: String(input.exitCode) }), + ...(input.stderr === undefined ? {} : { T3_FAKE_MUSE_STDERR: input.stderr }), + }; + const textGeneration = yield* makeMuseTextGeneration( + decodeMuseSettings({ binaryPath, launchArgs: input.launchArgs ?? "" }), + environment, + ); + return yield* use(textGeneration, { argsFile, promptFile }); + }), + ); +} + +it("parses terminal output and falls back to output deltas", () => { + expect( + parseMuseHeadlessOutput( + [ + "not-json", + museEvent("run.output.delta", { text: "first " }, 1), + museEvent("run.output.delta", { text: "second" }, 2), + ].join("\n"), + ), + ).toEqual({ terminal: null, text: "first second" }); +}); + +it.layer(NodeServices.layer)("MuseTextGeneration", (it) => { + it.effect("uses headless safe mode with the requested model and reasoning effort", () => + withFakeMuse( + { + output: museTerminalEvent( + `Here is the result:\n${JSON.stringify({ title: "Add Muse provider support" })}`, + ), + launchArgs: [ + "--max-model-steps 4", + "--yolo", + "--disable-sandbox", + "--worktree create", + "--base-url https://example.invalid", + "--provider echo", + "--prompt-file /tmp/unmanaged-prompt", + ].join(" "), + }, + (textGeneration, capture) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please add Meta Muse Code as a first-class provider.", + modelSelection: createModelSelection( + ProviderInstanceId.make("muse"), + "muse-spark-1.2", + [{ id: "reasoningEffort", value: "xhigh" }], + ), + }); + expect(generated.title).toBe("Add Muse provider support"); + + const fs = yield* FileSystem.FileSystem; + const args = (yield* fs.readFileString(capture.argsFile)).split("\0").filter(Boolean); + expect(args[0]).toBe("exec"); + expect(args).toContain("--max-model-steps"); + expect(args).toContain("4"); + expect(args).not.toContain("--yolo"); + expect(args).not.toContain("--disable-sandbox"); + expect(args).not.toContain("--worktree"); + expect(args).not.toContain("--base-url"); + expect(args).toContain("--json"); + expect(args.slice(args.indexOf("--provider"), args.indexOf("--provider") + 2)).toEqual([ + "--provider", + "meta", + ]); + expect(args).toContain("--disable-approval"); + expect(args).toContain("--disable-write"); + expect(args).toContain("--disable-shell"); + expect(args).toContain("--disable-web-tools"); + expect(args).toContain("--no-session-log"); + expect(args).toContain("--prompt-file"); + expect(args.filter((arg) => arg === "--provider")).toHaveLength(1); + expect(args.filter((arg) => arg === "--prompt-file")).toHaveLength(1); + expect(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2)).toEqual([ + "--model", + "muse-spark-1.2", + ]); + expect( + args.slice(args.indexOf("--reasoning-effort"), args.indexOf("--reasoning-effort") + 2), + ).toEqual(["--reasoning-effort", "xhigh"]); + const prompt = yield* fs.readFileString(capture.promptFile); + expect(prompt).toContain(""); + expect(prompt).toContain("You write concise thread titles"); + }), + ), + ); + + it.effect("uses high reasoning by default and decodes arbitrary structured output", () => + withFakeMuse( + { + output: museTerminalEvent(JSON.stringify({ approved: true })), + }, + (textGeneration, capture) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateStructured({ + cwd: process.cwd(), + prompt: "Decide whether this change is ready.", + outputSchema: Schema.Struct({ approved: Schema.Boolean }), + modelSelection: createModelSelection(ProviderInstanceId.make("muse"), "muse-spark-1.2"), + }); + expect(generated).toEqual({ approved: true }); + + const fs = yield* FileSystem.FileSystem; + const args = (yield* fs.readFileString(capture.argsFile)).split("\0").filter(Boolean); + expect( + args.slice(args.indexOf("--reasoning-effort"), args.indexOf("--reasoning-effort") + 2), + ).toEqual(["--reasoning-effort", "high"]); + }), + ), + ); + + it.effect("does not expose provider stderr when the command fails", () => + withFakeMuse( + { + output: "", + exitCode: 7, + stderr: "META_API_KEY=secret-value-that-must-not-leak", + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Add Muse", + modelSelection: createModelSelection( + ProviderInstanceId.make("muse"), + "muse-spark-1.2", + ), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toContain("code 7"); + expect(error.detail).not.toContain("secret-value"); + }), + ), + ); + + it.effect("rejects malformed structured output", () => + withFakeMuse({ output: museTerminalEvent("not json") }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: createModelSelection(ProviderInstanceId.make("muse"), "muse-spark-1.2"), + }), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toContain("invalid structured output"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/MuseTextGeneration.ts b/apps/server/src/textGeneration/MuseTextGeneration.ts new file mode 100644 index 000000000000..53abf139ce65 --- /dev/null +++ b/apps/server/src/textGeneration/MuseTextGeneration.ts @@ -0,0 +1,323 @@ +import { TextGenerationError, type ModelSelection, type MuseSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + makeMuseEnvironment, + resolveMuseReasoningEffort, +} from "../provider/Layers/MuseProvider.ts"; +import { + filterMuseLaunchArgs, + museOutputDelta, + museTerminalRecord, + parseMuseJsonLine, +} from "../provider/Layers/MuseProtocol.ts"; +import { spawnAndCollect } from "../provider/providerSnapshot.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + withStructuredOutputSchemaPrompt, +} from "./TextGenerationUtils.ts"; + +const MUSE_TIMEOUT_MS = 180_000; +const MUSE_MAX_EVENT_OUTPUT_BYTES = 8 * 1024 * 1024; +const isTextGenerationError = Schema.is(TextGenerationError); + +export interface MuseHeadlessOutput { + readonly text: string; + readonly terminal: string | null; +} + +/** Extract the final response from Muse's versioned JSONL event stream. */ +export function parseMuseHeadlessOutput(stdout: string): MuseHeadlessOutput { + let deltaText = ""; + let terminal: string | null = null; + let terminalText = ""; + + for (const line of stdout.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const parsed = parseMuseJsonLine(trimmed); + if (parsed.kind !== "event") { + continue; + } + const delta = museOutputDelta(parsed.event); + if (delta !== undefined) { + deltaText += delta; + } + const terminalRecord = museTerminalRecord(parsed.event); + if (terminalRecord) { + terminal = terminalRecord.terminal; + terminalText = terminalRecord.text ?? ""; + } + } + + return { + terminal, + text: (terminalText || deltaText).trim(), + }; +} + +export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(function* ( + museSettings: MuseSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const resolvedEnvironment = makeMuseEnvironment(environment); + + const runMuseJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle" + | "generateStructured"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const command = museSettings.binaryPath || "muse"; + const reasoningEffort = resolveMuseReasoningEffort( + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"), + ); + const launchArgs = filterMuseLaunchArgs(museSettings.launchArgs); + const promptFile = yield* fileSystem.makeTempFileScoped({ + prefix: "t3-muse-text-generation-", + suffix: ".md", + }); + yield* fileSystem.writeFileString( + promptFile, + withStructuredOutputSchemaPrompt(prompt, outputSchemaJson), + ); + const spawnCommand = yield* resolveSpawnCommand( + command, + [ + "exec", + ...launchArgs, + "--json", + "--provider", + "meta", + "--model", + modelSelection.model, + "--reasoning-effort", + reasoningEffort, + "--workspace", + cwd, + "--disable-approval", + "--disable-write", + "--disable-shell", + ...(launchArgs.includes("--disable-web-tools") ? [] : ["--disable-web-tools"]), + ...(launchArgs.includes("--no-foreign-personal-context") + ? [] + : ["--no-foreign-personal-context"]), + "--no-session-log", + "--prompt-file", + promptFile, + ], + { env: resolvedEnvironment }, + ); + const childCommand = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: resolvedEnvironment, + cwd, + shell: spawnCommand.shell, + }); + + const result = yield* spawnAndCollect(command, childCommand, { + maxOutputBytes: MUSE_MAX_EVENT_OUTPUT_BYTES, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), + Effect.mapError((cause) => + normalizeCliError("muse", operation, cause, "Failed to run Muse Code."), + ), + Effect.timeoutOption(MUSE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Muse Code request timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + if (result.code !== 0) { + return yield* new TextGenerationError({ + operation, + detail: `Muse Code command failed with code ${result.code}.`, + }); + } + if (result.stdoutTruncated) { + return yield* new TextGenerationError({ + operation, + detail: "Muse Code returned more event data than T3 Code can safely process.", + }); + } + + const output = parseMuseHeadlessOutput(result.stdout); + if (output.terminal !== "completed") { + return yield* new TextGenerationError({ + operation, + detail: + output.terminal === null + ? "Muse Code returned no terminal event." + : `Muse Code request ended with status '${output.terminal}'.`, + }); + } + if (!output.text) { + return yield* new TextGenerationError({ + operation, + detail: "Muse Code returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(output.text)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Muse Code returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : normalizeCliError("muse", operation, cause, "Failed to run Muse Code."), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("MuseTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runMuseJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("MuseTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runMuseJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("MuseTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runMuseJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("MuseTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runMuseJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + const generateStructured: TextGeneration.TextGeneration["Service"]["generateStructured"] = + Effect.fn("MuseTextGeneration.generateStructured")(function* (input) { + return yield* runMuseJson({ + operation: "generateStructured", + cwd: input.cwd, + prompt: input.prompt, + outputSchemaJson: input.outputSchema, + modelSelection: input.modelSelection, + }); + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + generateStructured, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index d1612ccd4eeb..20a293b477bc 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "muse" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..e63317567641 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,12 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const MuseCodeIcon: Icon = ({ className, ...props }) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts new file mode 100644 index 000000000000..9d651ba6eeca --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -0,0 +1,22 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { MuseCodeIcon } from "../Icons"; +import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; + +describe("Muse Code provider presentation", () => { + const muse = ProviderDriverKind.make("muse"); + + it("appears as a selectable new provider", () => { + expect(AVAILABLE_PROVIDER_OPTIONS).toContainEqual({ + value: muse, + label: "Muse Code", + available: true, + pickerSidebarBadge: "new", + }); + }); + + it("uses the Meta brand icon", () => { + expect(PROVIDER_ICON_BY_PROVIDER[muse]).toBe(MuseCodeIcon); + }); +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a7007168..f684205e67ad 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, MuseCodeIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("muse")]: MuseCodeIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c15519..408bd980a5ba 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { useAtomValue } from "@effect/atom-react"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { @@ -12,6 +13,7 @@ import { import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { primaryServerProvidersAtom } from "../../state/server"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { @@ -26,7 +28,11 @@ import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; -import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; +import { + DRIVER_OPTION_BY_VALUE, + DRIVER_OPTIONS, + runtimeSupportedDriverOptions, +} from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; import { AnimatedHeight } from "../AnimatedHeight"; import { @@ -122,6 +128,7 @@ interface AddProviderInstanceDialogProps { export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -139,6 +146,10 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns () => new Set(Object.keys(settings.providerInstances ?? {})), [settings.providerInstances], ); + const driverOptions = useMemo( + () => runtimeSupportedDriverOptions(serverProviders), + [serverProviders], + ); const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; const instanceId = instanceIdOverride ?? deriveInstanceId(driver, label); @@ -253,7 +264,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns aria-labelledby="add-instance-driver-label" className="grid grid-cols-1 gap-2 sm:grid-cols-2" > - {DRIVER_OPTIONS.map((option) => { + {driverOptions.map((option) => { const IconComponent = option.icon; return ( { }); }); + it("exposes the Muse Code CLI fields without surfacing internal model storage", () => { + const muse = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("muse")]; + + expect(muse).toMatchObject({ label: "Muse Code", badgeLabel: "Beta" }); + expect(deriveProviderSettingsFields(muse!).map((field) => field.key)).toEqual([ + "binaryPath", + "launchArgs", + ]); + }); + + it("hides Muse when the server withholds it or reports only a stale unavailable shadow", () => { + const muse = ProviderDriverKind.make("muse"); + + expect(isProviderDriverRuntimeSupported(muse, [])).toBe(false); + expect( + isProviderDriverRuntimeSupported(muse, [{ driver: muse, availability: "unavailable" }]), + ).toBe(false); + expect(runtimeSupportedDriverOptions([]).map((option) => option.value)).not.toContain(muse); + }); + + it("shows Muse when the server registers a concrete Muse instance", () => { + const muse = ProviderDriverKind.make("muse"); + + expect( + isProviderDriverRuntimeSupported(muse, [{ driver: muse, availability: "available" }]), + ).toBe(true); + expect( + runtimeSupportedDriverOptions([{ driver: muse, availability: "available" }]).map( + (option) => option.value, + ), + ).toContain(muse); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6f2686c55561..1875c37d6254 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -87,7 +87,11 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { AuthConnectorDialog } from "./AuthConnectorDialog"; import { AGENT_AUTH_METHODS } from "./authConnectorMethods"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { + DRIVER_OPTIONS, + getDriverOption, + isProviderDriverRuntimeSupported, +} from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, @@ -1132,11 +1136,12 @@ export function ProviderSettingsPanel() { ); const visibleProviderSettings = PROVIDER_SETTINGS.filter( (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), + isProviderDriverRuntimeSupported(providerSettings.provider, serverProviders) && + (providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + )), ); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; @@ -1288,6 +1293,7 @@ export function ProviderSettingsPanel() { } for (const [driver, list] of instancesByDriver) { if (visibleDriverKinds.has(driver)) continue; + if (!isProviderDriverRuntimeSupported(driver, serverProviders)) continue; for (const [id, instance] of list) { rows.push({ instanceId: id, diff --git a/apps/web/src/components/settings/authConnectorMethods.test.ts b/apps/web/src/components/settings/authConnectorMethods.test.ts new file mode 100644 index 000000000000..34d41259032a --- /dev/null +++ b/apps/web/src/components/settings/authConnectorMethods.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { AGENT_AUTH_METHODS } from "./authConnectorMethods"; + +describe("Muse authentication methods", () => { + it("offers Meta account and API-key connections", () => { + expect(AGENT_AUTH_METHODS.muse).toMatchObject({ + connector: "muse", + serviceName: "Muse Code", + methods: [ + { method: "account" }, + { + method: "api-key", + externalHelpUrl: "https://dev.meta.ai/", + externalHelpLabel: "Create a Meta API key", + }, + ], + }); + }); +}); diff --git a/apps/web/src/components/settings/authConnectorMethods.ts b/apps/web/src/components/settings/authConnectorMethods.ts index a6b8fd6b8559..8f3670b5d578 100644 --- a/apps/web/src/components/settings/authConnectorMethods.ts +++ b/apps/web/src/components/settings/authConnectorMethods.ts @@ -88,6 +88,28 @@ export const AGENT_AUTH_METHODS: Partial< }, ], }, + muse: { + connector: "muse", + serviceName: "Muse Code", + methods: [ + { + method: "account", + label: "Sign in with Meta", + description: "Connect your Meta account with Muse Code's secure device flow.", + browserName: "Meta", + authorizeInstruction: + "Confirm that the one-time code matches on Meta’s authorization page, then approve access.", + waitingMessage: "Waiting for Meta to confirm the code…", + }, + { + method: "api-key", + label: "Use a Meta API key", + description: "Authenticate Muse Code with a Meta API key.", + externalHelpUrl: "https://dev.meta.ai/", + externalHelpLabel: "Create a Meta API key", + }, + ], + }, opencode: { connector: "opencode", serviceName: "OpenCode", diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..0b48aca394a2 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,21 @@ import { CodexSettings, CursorSettings, GrokSettings, + MuseSettings, OpenCodeSettings, ProviderDriverKind, + type ServerProvider, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + MuseCodeIcon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +71,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("muse"), + label: "Muse Code", + icon: MuseCodeIcon, + badgeLabel: "Beta", + settingsSchema: MuseSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", @@ -79,6 +96,36 @@ export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS; export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE; export type DriverOption = ProviderClientDefinition; +const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); + +type ProviderRuntimeSupportSnapshot = Pick; + +/** + * Muse can be withheld by a managed server while remaining compiled into the + * shared client artifact. Only expose it when the server reports a concrete, + * available Muse instance. An unavailable shadow may come from stale settings + * and is not evidence that the driver can execute in this environment. + */ +export function isProviderDriverRuntimeSupported( + driver: ProviderDriverKind, + providers: ReadonlyArray, +): boolean { + return ( + driver !== MUSE_DRIVER_KIND || + providers.some( + (provider) => provider.driver === MUSE_DRIVER_KIND && provider.availability !== "unavailable", + ) + ); +} + +export function runtimeSupportedDriverOptions( + providers: ReadonlyArray, +): ReadonlyArray { + return DRIVER_OPTIONS.filter((option) => + isProviderDriverRuntimeSupported(option.value, providers), + ); +} + /** * Look up the driver metadata for an instance's `driver` field. Accepts * Returns `undefined` for fork / unknown drivers so callers can decide how diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c3226884a31d..8eb6b7e6f950 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; +import { + deriveLatestContextWindowSnapshot, + formatContextWindowTokens, + formatProviderDisplayName, +} from "./contextWindow"; function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { return { @@ -81,4 +85,8 @@ describe("contextWindow", () => { expect(snapshot?.usedTokens).toBe(81_659); expect(snapshot?.totalProcessedTokens).toBe(748_126); }); + + it("uses the product name for Muse Code context feedback", () => { + expect(formatProviderDisplayName("muse")).toBe("Muse Code"); + }); }); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..0272cb22354f 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -36,6 +36,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Codex"; case "cursor": return "Cursor"; + case "muse": + return "Muse Code"; case "opencode": return "OpenCode"; default: { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8d52a2cb7bf1..407dcc39f98f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("muse"), + label: "Muse Code", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index fe473094bbcf..5476d7d3ddf0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,5 +15,8 @@ - [Integrations](./integrations/source-control-providers.md) - [Mobile](./mobile/app.md) - [Operations](./operations/ci.md) -- [Providers](./providers/codex.md) +- Providers + - [Codex](./providers/codex.md) + - [Claude](./providers/claude.md) + - [Muse Code](./providers/muse.md) - [Reference](./reference/encyclopedia.md) diff --git a/docs/providers/muse.md b/docs/providers/muse.md new file mode 100644 index 000000000000..28e3f1fbb7d9 --- /dev/null +++ b/docs/providers/muse.md @@ -0,0 +1,49 @@ +# Muse Code + +Muse Code is a Beta provider in T3 Code. It uses Meta's `muse exec` headless interface and +defaults to the Muse Spark 1.2 model. + +## Install And Connect + +Install Muse Code using [Meta's instructions](https://research.meta.ai/blog/introducing-muse-code-and-muse-spark-1-2), then connect it from the Muse Code card in T3 Code Settings. T3 Code supports both of the CLI's authentication methods: + +- **Sign in with Meta** starts the `muse login` device flow and opens Meta's authorization page. +- **Use a Meta API key** securely sends a key to `muse auth set --provider meta --api-key-stdin`. + +You can also authenticate before starting T3 Code by running `muse login`, or provide a +`META_API_KEY` environment variable on a separate Muse provider instance. + +## What Works + +Muse sessions keep a stable Muse session ID across turns, so normal conversation continuation +works after T3 Code reconnects. The integration also supports: + +- streamed responses and the canonical T3 work log for Muse tool activity +- Muse Spark model selection and reasoning-effort controls +- image and file attachments +- Plan mode, with write and shell tools disabled +- turn interruption and queued follow-up messages +- commit-message and thread-title generation through an isolated Muse invocation + +## Runtime Modes + +Muse's headless protocol does not currently expose a way for T3 Code to answer an approval prompt. +T3 Code maps its runtime modes accordingly: + +- **Supervised** runs Muse read-only so an invisible approval request cannot hang the turn. +- **Auto** allows sandboxed changes without interactive approval. +- **Full Access** uses Muse's trusted, unsandboxed mode. +- **Plan** is always read-only, regardless of the selected runtime mode. + +Muse also does not currently expose mid-turn steering or durable rollback through its headless +interface. Interrupt a running turn before sending a replacement instruction. + +## Context And Privacy + +Normal Muse sessions retain the CLI's standard workspace context behavior, including compatible +personal instructions and skills it discovers on the machine. T3 Code's short-lived text-generation +helpers disable foreign personal context and session logging so commit messages and thread titles do +not inherit unrelated instructions or leave extra Muse histories. + +T3 Code detects whether Muse has a stored Meta credential by inspecting only the shape of Muse's +local auth record; it never reads a credential value into provider status or sends it to the browser. diff --git a/packages/contracts/src/authConnector.test.ts b/packages/contracts/src/authConnector.test.ts new file mode 100644 index 000000000000..c45328fecfa4 --- /dev/null +++ b/packages/contracts/src/authConnector.test.ts @@ -0,0 +1,15 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { AuthConnectorStartInput } from "./authConnector.ts"; + +const decodeStartInput = Schema.decodeUnknownSync(AuthConnectorStartInput); + +describe("AuthConnectorStartInput", () => { + it.each(["account", "api-key"] as const)("accepts Muse %s authentication", (method) => { + expect(decodeStartInput({ connector: "muse", method })).toEqual({ + connector: "muse", + method, + }); + }); +}); diff --git a/packages/contracts/src/authConnector.ts b/packages/contracts/src/authConnector.ts index 4b9808673f5c..dec7155d737c 100644 --- a/packages/contracts/src/authConnector.ts +++ b/packages/contracts/src/authConnector.ts @@ -7,6 +7,7 @@ export const AuthConnectorKind = Schema.Literals([ "claude", "cursor", "grok", + "muse", "opencode", "github", "gitlab", diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts new file mode 100644 index 000000000000..0c0556f4cbfa --- /dev/null +++ b/packages/contracts/src/model.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderDriverKind } from "./providerInstance.ts"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + DEFAULT_MODEL_BY_PROVIDER, + PROVIDER_DISPLAY_NAMES, +} from "./model.ts"; + +describe("Muse Code model metadata", () => { + const muse = ProviderDriverKind.make("muse"); + + it("uses Muse Spark 1.2 as the interactive and text-generation default", () => { + expect(DEFAULT_MODEL_BY_PROVIDER[muse]).toBe("muse-spark-1.2"); + expect(DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[muse]).toBe("muse-spark-1.2"); + }); + + it("uses the full product name in provider presentation", () => { + expect(PROVIDER_DISPLAY_NAMES[muse]).toBe("Muse Code"); + }); +}); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8c74c13b89b4..176131303084 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -151,6 +152,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [MUSE_DRIVER_KIND]: "Muse Code", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff004..00e5ac11f9be 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,6 +26,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("muse.exec.event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e174..ad2508b22177 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -124,6 +124,35 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("Muse Code provider settings", () => { + it("defaults to the Muse CLI and keeps optional configuration empty", () => { + const muse = decodeServerSettings({}).providers.muse; + + expect(muse).toEqual({ + enabled: true, + binaryPath: "muse", + launchArgs: "", + customModels: [], + }); + }); + + it("trims Muse CLI settings in partial updates", () => { + const patch = decodeServerSettingsPatch({ + providers: { + muse: { + binaryPath: " /opt/aldo/bin/muse ", + launchArgs: " --reasoning-effort ultra ", + }, + }, + }); + + expect(patch.providers?.muse).toEqual({ + binaryPath: "/opt/aldo/bin/muse", + launchArgs: "--reasoning-effort ultra", + }); + }); +}); + describe("ServerSettings worktree defaults", () => { it("defaults start-from-origin on for legacy configs", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 924f41de4baf..1cb66471851e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -339,6 +339,38 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const MuseSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("muse").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Muse Code binary used by this instance.", + providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to muse exec on each turn.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type MuseSettings = typeof MuseSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -432,6 +464,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + muse: MuseSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -528,6 +561,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const MuseSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -557,6 +597,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + muse: Schema.optionalKey(MuseSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/scripts/deploy-relay-workflow.test.ts b/scripts/deploy-relay-workflow.test.ts new file mode 100644 index 000000000000..985f25d50c39 --- /dev/null +++ b/scripts/deploy-relay-workflow.test.ts @@ -0,0 +1,31 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { assert, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as YAML from "yaml"; + +const repositoryRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", +); + +interface RelayWorkflow { + readonly on?: Readonly>; +} + +it("requires an explicit dispatch before deploying the production relay", () => { + const workflowPath = NodePath.join(repositoryRoot, ".github", "workflows", "deploy-relay.yml"); + const workflow = YAML.parse(NodeFS.readFileSync(workflowPath, "utf8")) as RelayWorkflow; + const triggers = workflow.on; + + assert.ok(triggers, "Relay workflow is missing its trigger configuration"); + assert.ok( + Object.hasOwn(triggers, "workflow_dispatch"), + "Relay workflow must retain an explicit manual trigger", + ); + assert.ok( + !Object.hasOwn(triggers, "push"), + "Relay workflow must not deploy production from a repository push", + ); +}); diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts new file mode 100644 index 000000000000..23d1c840e053 --- /dev/null +++ b/scripts/release-workflow.test.ts @@ -0,0 +1,31 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { assert, it } from "@effect/vitest"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as YAML from "yaml"; + +const repositoryRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "..", +); + +interface ReleaseWorkflow { + readonly on?: Readonly>; +} + +it("does not publish public nightlies on a schedule", () => { + const workflowPath = NodePath.join(repositoryRoot, ".github", "workflows", "release.yml"); + const workflow = YAML.parse(NodeFS.readFileSync(workflowPath, "utf8")) as ReleaseWorkflow; + const triggers = workflow.on; + + assert.ok(triggers, "Release workflow is missing its trigger configuration"); + assert.ok( + Object.hasOwn(triggers, "workflow_dispatch"), + "Release workflow must retain an explicit manual trigger", + ); + assert.ok( + !Object.hasOwn(triggers, "schedule"), + "Release workflow must not publish public nightlies automatically", + ); +});