diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd85474..c6827cd57739f 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -21,6 +21,14 @@ const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; +const useVibeConfig = process.env.T3_ACP_VIBE_CONFIG === "1"; +const vibeTrustStatus = process.env.T3_ACP_VIBE_TRUST_STATUS ?? "trusted"; +// Decisions Vibe currently has pending. Empty means no trust prompt is +// outstanding, and `_trust/decision` is rejected the way the real agent does. +const vibeTrustDecisions = (process.env.T3_ACP_VIBE_TRUST_DECISIONS ?? "") + .split(",") + .map((decision) => decision.trim()) + .filter((decision) => decision.length > 0); const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; @@ -40,6 +48,7 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); +const setConfigOptionDelayMs = Number(process.env.T3_ACP_SET_CONFIG_OPTION_DELAY_MS ?? "0"); const permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", @@ -47,8 +56,8 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; -let currentModeId = "ask"; -let currentModelId = "default"; +let currentModeId = useVibeConfig ? "default" : "ask"; +let currentModelId = useVibeConfig ? "mistral-medium-3.5" : "default"; let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; @@ -56,6 +65,22 @@ let currentFast = false; let promptCount = 0; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); +const pendingCancellationResolvers = new Map void>(); + +function awaitCancellation(cancelledSessionId: string): Effect.Effect { + return Effect.callback((resume) => { + const resolve = () => resume(Effect.void); + pendingCancellationResolvers.set(cancelledSessionId, resolve); + if (cancelledSessions.has(cancelledSessionId)) { + resolve(); + } + return Effect.sync(() => { + if (pendingCancellationResolvers.get(cancelledSessionId) === resolve) { + pendingCancellationResolvers.delete(cancelledSessionId); + } + }); + }); +} function promptIdFromRequestMeta( request: Pick, @@ -94,6 +119,47 @@ process.once("exit", (code) => { }); function configOptions(): ReadonlyArray { + if (useVibeConfig) { + return [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentModeId, + options: availableModes.map((mode) => ({ + value: mode.id, + name: mode.name, + ...(mode.description ? { description: mode.description } : {}), + })), + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "mistral-medium-3.5", name: "Mistral Medium 3.5" }, + { value: "devstral-small", name: "Devstral Small" }, + { value: "local", name: "Local" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config", + type: "select", + currentValue: "high", + options: [ + { value: "off", name: "Off" }, + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ]; + } + if (parameterizedModelPicker) { const baseOptions: Array = [ { @@ -253,23 +319,30 @@ function availableModels(): ReadonlyArray<{ })); } -const availableModes: ReadonlyArray = [ - { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", - }, - { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", - }, -]; +const availableModes: ReadonlyArray = useVibeConfig + ? [ + { id: "default", name: "Default" }, + { id: "accept-edits", name: "Accept edits" }, + { id: "auto-approve", name: "Auto approve" }, + { id: "plan", name: "Plan" }, + ] + : [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, + ]; function modeState(): AcpSchema.SessionModeState { return { @@ -282,14 +355,20 @@ const grokAcpModels: ReadonlyArray = [ { modelId: "grok-build", name: "Grok Build" }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; +const vibeAcpModels: ReadonlyArray = [ + { modelId: "mistral-medium-3.5", name: "Mistral Medium 3.5" }, + { modelId: "devstral-small", name: "Devstral Small" }, + { modelId: "local", name: "Local" }, +]; function modelState(): AcpSchema.SessionModelState { - const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) + const availableModels = useVibeConfig ? vibeAcpModels : grokAcpModels; + const modelId = availableModels.some((model) => model.modelId === currentModelId) ? currentModelId - : "grok-build"; + : availableModels[0]!.modelId; return { currentModelId: modelId, - availableModels: grokAcpModels, + availableModels, }; } @@ -382,7 +461,8 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionModel((request) => Effect.gen(function* () { - if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { + const availableModels = useVibeConfig ? vibeAcpModels : grokAcpModels; + if (!availableModels.some((model) => model.modelId === request.modelId)) { return yield* AcpError.AcpRequestError.invalidParams( `Unknown mock model id: ${request.modelId}`, { @@ -403,6 +483,9 @@ const program = Effect.gen(function* () { process.exit(7); }); } + if (Number.isFinite(setConfigOptionDelayMs) && setConfigOptionDelayMs > 0) { + yield* Effect.sleep(`${setConfigOptionDelayMs} millis`); + } if (failSetConfigOption) { return yield* AcpError.AcpRequestError.invalidParams( "Mock invalid params for session/set_config_option", @@ -449,6 +532,7 @@ const program = Effect.gen(function* () { }); }); } + pendingCancellationResolvers.get(cancelledSessionId)?.(); }), ); @@ -519,7 +603,9 @@ const program = Effect.gen(function* () { } if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { - return yield* Effect.never; + yield* awaitCancellation(requestedSessionId); + cancelledSessions.delete(requestedSessionId); + return { stopReason: "cancelled" }; } if (emitXAiPromptCompleteThenHang) { @@ -878,6 +964,35 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "_trust/status") { + return Effect.succeed({ + trust_status: vibeTrustStatus, + details: + vibeTrustDecisions.length > 0 + ? { + cwd: process.cwd(), + repoRoot: null, + ignoredFiles: [], + availableDecisions: vibeTrustDecisions, + } + : null, + }); + } + if (method === "_trust/decision") { + const requested = + typeof params === "object" && params !== null && "decision" in params + ? params.decision + : undefined; + // Matches the real agent: a decision can only be answered while one is + // pending, otherwise `-32602 No workspace trust decision is available`. + if (typeof requested !== "string" || !vibeTrustDecisions.includes(requested)) { + return Effect.fail( + AcpError.AcpRequestError.invalidParams("No workspace trust decision is available."), + ); + } + return Effect.succeed({ trust_status: "trusted", details: null }); + } + if (method === "cursor/list_available_models") { return Effect.succeed({ models: availableModels(), diff --git a/apps/server/src/provider/Drivers/VibeDriver.ts b/apps/server/src/provider/Drivers/VibeDriver.ts new file mode 100644 index 0000000000000..7e0fb5e0d09a1 --- /dev/null +++ b/apps/server/src/provider/Drivers/VibeDriver.ts @@ -0,0 +1,160 @@ +import { ProviderDriverKind, type ServerProvider, VibeSettings } 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 { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeVibeTextGeneration } from "../../textGeneration/VibeTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeVibeAdapter } from "../Layers/VibeAdapter.ts"; +import { + buildInitialVibeProviderSnapshot, + checkVibeProviderStatus, + enrichVibeSnapshot, +} from "../Layers/VibeProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeSettings = Schema.decodeSync(VibeSettings); +const DRIVER_KIND = ProviderDriverKind.make("vibe"); +const REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "mistral-vibe", + }), +); + +export type VibeDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withIdentity = + (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 VibeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Mistral Vibe", + supportsMultipleInstances: true, + }, + configSchema: VibeSettings, + defaultConfig: (): VibeSettings => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies VibeSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + const adapter = yield* makeVibeAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeVibeTextGeneration(effectiveConfig, processEnv); + const checkProvider = checkVibeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialVibeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichVibeSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + refreshInterval: REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Mistral Vibe 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/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dcc3ac0b5db75..9bd74454d48e5 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`, `vibe`, `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,7 @@ * * 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` / `vibe-acp` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -33,6 +33,7 @@ import { ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, + type VibeSettings, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { VibeDriver } from "../Drivers/VibeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -124,6 +126,17 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeVibeConfig = (overrides: Partial): VibeSettings => ({ + enabled: false, + binaryPath: "vibe-acp", + trustWorkspace: false, + ollamaEnabled: false, + ollamaBaseUrl: "http://127.0.0.1:11434/v1", + ollamaModel: "devstral-small-2", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -294,12 +307,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 vibeId = ProviderInstanceId.make("vibe_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 vibeDriverKind = ProviderDriverKind.make("vibe"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -330,6 +345,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [vibeId]: { + driver: vibeDriverKind, + displayName: "Mistral Vibe", + enabled: false, + config: makeVibeConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -339,7 +360,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, VibeDriver, OpenCodeDriver], configMap, }); @@ -349,9 +370,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, vibeId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -361,16 +382,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 vibe = yield* registry.getInstance(vibeId); 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(vibe?.driverKind).toBe(vibeDriverKind); 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(vibe?.displayName).toBe("Mistral Vibe"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -383,6 +407,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + vibe!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -391,6 +416,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + vibe!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -399,6 +425,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + vibe!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -435,6 +462,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const vibeSnapshot = yield* vibe!.snapshot.getSnapshot; + expect(vibeSnapshot.instanceId).toBe(vibeId); + expect(vibeSnapshot.driver).toBe(vibeDriverKind); + expect(vibeSnapshot.enabled).toBe(false); + expect(vibeSnapshot.continuation?.groupKey).toBe(`${vibeDriverKind}:instance:${vibeId}`); + 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 c78ecb3952a31..44f0be0194b7e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1555,6 +1555,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + vibe: { enabled: false }, }, }), ), @@ -1808,6 +1809,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "vibe", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts new file mode 100644 index 0000000000000..ac816b022b135 --- /dev/null +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -0,0 +1,732 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, + TurnId, + VibeSettings, +} from "@t3tools/contracts"; +import { assert, it } 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"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerConfig } from "../../config.ts"; +import { drainVibeEventsUnlessStopped, makeVibeAdapter } from "./VibeAdapter.ts"; + +const decodeVibeSettings = Schema.decodeSync(VibeSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockVibeWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-vibe.sh"); + const envExports = Object.entries({ T3_ACP_VIBE_CONFIG: "1", ...extraEnv }) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +const vibeAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-vibe-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, overrides?: { readonly trustWorkspace?: boolean }) => + makeVibeAdapter(decodeVibeSettings({ binaryPath, ...overrides })).pipe(Effect.orDie); + +function waitForFileContent(filePath: string, attempts = 80): Effect.Effect { + const readAttempt = (remainingAttempts: number): Effect.Effect => + Effect.gen(function* () { + if (remainingAttempts <= 0) { + return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); + } + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if (raw.trim().length > 0) return raw; + yield* Effect.sleep("25 millis"); + return yield* readAttempt(remainingAttempts - 1); + }); + return readAttempt(attempts); +} + +function waitForFileOccurrences( + filePath: string, + expected: string, + count: number, + attempts = 80, +): Effect.Effect { + const readAttempt = (remainingAttempts: number): Effect.Effect => + Effect.gen(function* () { + if (remainingAttempts <= 0) { + return yield* Effect.die( + new Error(`Timed out waiting for ${count} '${expected}' entries in ${filePath}`), + ); + } + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if (raw.split(expected).length - 1 >= count) return raw; + yield* Effect.sleep("25 millis"); + return yield* readAttempt(remainingAttempts - 1); + }); + return readAttempt(attempts); +} + +it.effect("stops waiting for ACP event drainage when the session stops", () => + Effect.gen(function* () { + const stoppedSignal = yield* Deferred.make(); + yield* Deferred.succeed(stoppedSignal, undefined); + + yield* drainVibeEventsUnlessStopped(Effect.never, stoppedSignal).pipe( + Effect.timeout("250 millis"), + ); + }), +); + +it.effect("ignores ACP event drainage failures so turn settlement can continue", () => + Effect.gen(function* () { + const stoppedSignal = yield* Deferred.make(); + + yield* drainVibeEventsUnlessStopped( + Effect.die(new Error("simulated drain failure")), + stoppedSignal, + ).pipe(Effect.timeout("250 millis")); + }), +); + +it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { + it.effect("restarts an existing thread when session inputs change", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockVibeWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-restart-thread"); + const firstCwd = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-first-cwd-")), + ); + const secondCwd = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-second-cwd-")), + ); + + const firstSession = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: firstCwd, + runtimeMode: "approval-required", + }); + const restartedSession = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: secondCwd, + runtimeMode: "full-access", + }); + + assert.equal(firstSession.cwd, NodePath.resolve(firstCwd)); + assert.equal(firstSession.runtimeMode, "approval-required"); + assert.equal(restartedSession.cwd, NodePath.resolve(secondCwd)); + assert.equal(restartedSession.runtimeMode, "full-access"); + assert.lengthOf(yield* adapter.listSessions(), 1); + }), + ); + + it.effect("serializes concurrent starts for the same thread", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-concurrent-start-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-concurrent-start-thread"); + const startInput = { + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access" as const, + }; + + const started = yield* Effect.all( + [adapter.startSession(startInput), adapter.startSession(startInput)], + { concurrency: "unbounded" }, + ); + + assert.lengthOf(started, 2); + assert.lengthOf(yield* adapter.listSessions(), 1); + assert.include(yield* waitForFileOccurrences(exitLogPath, "SIGTERM", 1), "SIGTERM"); + + yield* adapter.stopAll(); + assert.include(yield* waitForFileOccurrences(exitLogPath, "SIGTERM", 2), "SIGTERM"); + }), + ); + + it.effect("starts an untrusted workspace when automatic trust is disabled", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-untrusted-workspace-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_VIBE_TRUST_STATUS: "untrusted", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { trustWorkspace: false }); + const threadId = ThreadId.make("vibe-untrusted-workspace-thread"); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.status, "ready"); + const requests = yield* waitForFileContent(requestLogPath); + assert.include(requests, '"method":"_trust/status"'); + assert.notInclude(requests, '"method":"_trust/decision"'); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("starts an untrusted workspace that has no trust decision pending", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-untrusted-no-decision-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_VIBE_TRUST_STATUS: "untrusted", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { trustWorkspace: true }); + const threadId = ThreadId.make("vibe-untrusted-no-decision-thread"); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + // Vibe rejects `_trust/decision` when nothing is pending, which used to + // abort the whole session with "Extension request failed". + assert.equal(session.status, "ready"); + const requests = yield* waitForFileContent(requestLogPath); + assert.include(requests, '"method":"_trust/status"'); + assert.notInclude(requests, '"method":"_trust/decision"'); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("answers an outstanding Vibe trust prompt with an available decision", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-untrusted-decision-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_VIBE_TRUST_STATUS: "untrusted", + T3_ACP_VIBE_TRUST_DECISIONS: "trust_session,trust_cwd", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { trustWorkspace: true }); + const threadId = ThreadId.make("vibe-untrusted-decision-thread"); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.status, "ready"); + const requests = yield* waitForFileContent(requestLogPath); + assert.include(requests, '"method":"_trust/decision"'); + assert.include(requests, '"decision":"trust_cwd"'); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("serializes stopSession with an in-flight session start", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-start-stop-race-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "300", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-start-stop-race-thread"); + + const startFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* waitForFileContent(requestLogPath); + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + + yield* Fiber.join(startFiber); + yield* Fiber.join(stopFiber); + assert.isFalse(yield* adapter.hasSession(threadId)); + }).pipe(Effect.scoped, Effect.timeout("4 seconds"), TestClock.withLive), + ); + + it.effect("releases the reserved turn when prompt preparation fails", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => makeMockVibeWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-prompt-preparation-failure"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const failedTurn = yield* adapter + .sendTurn({ threadId, input: " ", attachments: [] }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedTurn)); + const [readySession] = yield* adapter.listSessions(); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + const retry = yield* adapter.sendTurn({ + threadId, + input: "continue after preparation failure", + attachments: [], + }); + assert.equal(retry.threadId, threadId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles a cancellation queued during turn preparation without prompting Vibe", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-prepare-cancel-race-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "300", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-prepare-cancel-race-thread"); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + events.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const requestsAfterStart = yield* waitForFileContent(requestLogPath); + const initialConfigRequestCount = + requestsAfterStart.split('"method":"session/set_config_option"').length - 1; + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "cancel while preparing", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("vibe"), + model: "devstral-small", + options: [{ id: "thinking", value: "low" }], + }, + }) + .pipe(Effect.forkChild); + yield* waitForFileOccurrences( + requestLogPath, + '"method":"session/set_config_option"', + initialConfigRequestCount + 1, + ); + yield* adapter.interruptTurn(threadId); + + const error = yield* Fiber.join(sendFiber).pipe(Effect.flip); + yield* Effect.yieldNow; + + const requests = yield* waitForFileOccurrences( + requestLogPath, + '"method":"session/cancel"', + 1, + ); + const started = events.filter( + (event) => event.type === "turn.started" && event.threadId === threadId, + ); + const completed = events.filter( + (event): event is Extract => + event.type === "turn.completed" && event.threadId === threadId, + ); + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.equal(error.method, "session/prompt"); + assert.equal(error.detail, "Mistral Vibe prompt was interrupted during preparation."); + } + // The turn never opened, so no terminal event is published either — the + // failed sendTurn is what the caller observes. Matches Cursor/Grok. + assert.lengthOf(started, 0); + assert.lengthOf(completed, 0); + assert.notInclude(requests, '"method":"session/prompt"'); + assert.include(requests, '"method":"session/cancel"'); + + const [readySession] = yield* adapter.listSessions(); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("5 seconds"), TestClock.withLive), + ); + + it.effect("settles a cancelled prompt once and permits a follow-up turn", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-in-flight-cancel-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + T3_ACP_PROMPT_DELAY_MS: "200", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-interrupt-turn-race"); + const events: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + events.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.started" && event.turnId !== undefined + ? Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendFiber = yield* adapter + .sendTurn({ threadId, input: "cancel the first prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/prompt"', 1); + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendFiber).pipe(Effect.timeout("2 seconds")); + + const followUp = yield* adapter + .sendTurn({ threadId, input: "complete the follow-up", attachments: [] }) + .pipe(Effect.timeout("2 seconds")); + + const completed = events.filter( + (event): event is Extract => + event.type === "turn.completed" && event.threadId === threadId, + ); + const lateDelta = events.find( + (event): event is Extract => + event.type === "content.delta" && event.payload.delta === "late after cancel", + ); + + assert.deepEqual( + completed.map((event) => [event.turnId, event.payload.state]), + [ + [firstTurnId, "cancelled"], + [followUp.turnId, "completed"], + ], + ); + assert.isUndefined(lateDelta); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("6 seconds"), TestClock.withLive), + ); + + it.effect("cancels a running prompt while a steering prompt waits to start", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-steering-cancel-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "100", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-steering-cancel"); + const firstSendSettled = yield* Deferred.make(); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + events.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const requestsAfterStart = yield* waitForFileContent(requestLogPath); + const initialConfigRequestCount = + requestsAfterStart.split('"method":"session/set_config_option"').length - 1; + + const firstSendFiber = yield* adapter + .sendTurn({ threadId, input: "keep running", attachments: [] }) + .pipe( + Effect.ensuring(Deferred.succeed(firstSendSettled, undefined).pipe(Effect.ignore)), + Effect.forkChild, + ); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/prompt"', 1); + + const steeringSendFiber = yield* adapter + .sendTurn({ + threadId, + input: "steer the running turn", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("vibe"), + model: "mistral-medium-3.5", + options: [{ id: "thinking", value: "low" }], + }, + }) + .pipe(Effect.forkChild); + yield* waitForFileOccurrences( + requestLogPath, + '"method":"session/set_config_option"', + initialConfigRequestCount + 1, + ); + yield* Effect.sleep("150 millis"); + assert.isFalse(yield* Deferred.isDone(firstSendSettled)); + + const interruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/cancel"', 1, 20); + assert.isFalse(yield* Deferred.isDone(firstSendSettled)); + + yield* Fiber.join(interruptFiber); + yield* Fiber.join(firstSendFiber); + yield* Fiber.join(steeringSendFiber); + const completed = events.filter( + (event): event is Extract => + event.type === "turn.completed" && event.threadId === threadId, + ); + assert.lengthOf(completed, 1); + assert.equal(completed[0]?.payload.state, "cancelled"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("5 seconds"), TestClock.withLive), + ); + + it.effect("releases the active turn when the sendTurn caller is interrupted", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-caller-interrupt-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-caller-interrupt"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendFiber = yield* adapter + .sendTurn({ threadId, input: "abandon me", attachments: [] }) + .pipe(Effect.forkChild); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/prompt"', 1); + yield* Fiber.interrupt(sendFiber); + + const [abandonedSession] = yield* adapter.listSessions(); + assert.equal(abandonedSession?.status, "ready"); + assert.isUndefined(abandonedSession?.activeTurnId); + + const followUp = yield* adapter + .sendTurn({ threadId, input: "continue after the caller went away", attachments: [] }) + .pipe(Effect.timeout("2 seconds")); + assert.equal(followUp.threadId, threadId); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("6 seconds"), TestClock.withLive), + ); + + it.effect("steers a running turn instead of opening a second Vibe turn", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-concurrent-turn-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_DELAY_MS: "1200", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-concurrent-send-turn"); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + events.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstFiber = yield* adapter + .sendTurn({ + threadId, + input: "first concurrent turn", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("vibe"), + model: "mistral-medium-3.5", + options: [{ id: "thinking", value: "low" }], + }, + }) + .pipe(Effect.forkChild); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/prompt"', 1); + const second = yield* adapter + .sendTurn({ threadId, input: "second concurrent turn", attachments: [] }) + .pipe(Effect.timeout("4 seconds")); + const first = yield* Fiber.join(firstFiber); + + assert.equal(String(first.turnId), String(second.turnId)); + + const started = events.filter( + (event) => event.type === "turn.started" && event.threadId === threadId, + ); + const completed = events.filter( + (event): event is Extract => + event.type === "turn.completed" && event.threadId === threadId, + ); + assert.equal(started.length, 1); + assert.deepEqual( + completed.map((event) => event.payload.state), + ["completed"], + ); + assert.equal(completed[0]?.turnId, started[0]?.turnId); + const requests = yield* waitForFileOccurrences( + requestLogPath, + '"method":"session/prompt"', + 2, + ); + assert.equal(requests.split('"method":"session/prompt"').length - 1, 2); + const thread = yield* adapter.readThread(threadId); + assert.lengthOf(thread.turns, 1); + assert.lengthOf(thread.turns[0]?.items ?? [], 2); + const [session] = yield* adapter.listSessions(); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("6 seconds"), TestClock.withLive), + ); + + it.effect("stops active ACP sessions when the adapter scope closes", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "vibe-adapter-finalizer-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ T3_ACP_EXIT_LOG_PATH: exitLogPath }), + ); + const adapterScope = yield* Scope.make(); + const adapter = yield* makeTestAdapter(wrapperPath).pipe( + Effect.provideService(Scope.Scope, adapterScope), + ); + const threadId = ThreadId.make("vibe-finalizer-thread"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + assert.isTrue(yield* adapter.hasSession(threadId)); + + yield* Scope.close(adapterScope, Exit.void); + + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.include(yield* waitForFileContent(exitLogPath), "SIGTERM"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts new file mode 100644 index 0000000000000..f0e7172874369 --- /dev/null +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -0,0 +1,1030 @@ +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeRequestId, + type ThreadId, + TurnId, + type VibeSettings, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + applyVibeSessionConfiguration, + makeVibeAcpRuntime, + readVibeWorkspaceTrust, + resolveVibeModelId, + selectVibeTrustDecision, + trustVibeWorkspace, + vibeModeForRuntimeMode, +} from "../acp/VibeAcpSupport.ts"; +import type { VibeAdapterShape } from "../Services/VibeAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("vibe"); +const RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface VibeAdapterOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface VibeActiveTurn { + readonly id: TurnId; + started: boolean; + interrupted: boolean; + promptsInFlight: number; +} + +interface VibeSessionContext { + readonly threadId: ThreadId; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly pendingApprovals: Map; + readonly stoppedSignal: Deferred.Deferred; + session: ProviderSession; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurn?: VibeActiveTurn; + stopped: boolean; +} + +export function drainVibeEventsUnlessStopped( + drainEvents: Effect.Effect, + stoppedSignal: Deferred.Deferred, +): Effect.Effect { + return Effect.raceFirst(drainEvents, Deferred.await(stoppedSignal)).pipe( + Effect.catchCause(() => Effect.void), + ); +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function resumeSessionId(raw: unknown): string | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const record = raw as Record; + return record.schemaVersion === RESUME_VERSION && + typeof record.sessionId === "string" && + record.sessionId.trim() + ? record.sessionId.trim() + : undefined; +} + +export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOptions) { + return Effect.gen(function* () { + const instanceId = options?.instanceId ?? ProviderInstanceId.make("vibe"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const events = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextId = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Mistral Vibe runtime identifier.", + cause, + }), + ), + ); + const stamp = () => + Effect.all({ + eventId: Effect.map(nextId, EventId.make), + createdAt: nowIso, + }); + const publish = (event: ProviderRuntimeEvent) => + PubSub.publish(events, event).pipe(Effect.asVoid); + const requireSession = (threadId: ThreadId) => { + const ctx = sessions.get(threadId); + return !ctx || ctx.stopped + ? Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })) + : Effect.succeed(ctx); + }; + 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 callbackError = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process a Mistral Vibe ACP callback.", + cause, + }), + ), + ); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* nextId, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Mistral Vibe notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: VibeSessionContext, + turnId: TurnId, + eventStamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* publish( + makeAcpPlanUpdatedEvent({ + stamp: eventStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload, + }), + ); + }); + + const stopContext = (ctx: VibeSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* Deferred.succeed(ctx.stoppedSignal, undefined).pipe(Effect.ignore); + yield* Effect.forEach( + ctx.pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + yield* Scope.close(ctx.scope, Exit.void).pipe(Effect.ignore); + sessions.delete(ctx.threadId); + yield* publish({ + type: "session.exited", + ...(yield* stamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSessionUnlocked: VibeAdapterShape["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.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopContext(existing); + } + + const cwd = path.resolve(input.cwd.trim()); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred + ? Effect.void + : Effect.sync(() => { + sessions.delete(input.threadId); + }).pipe(Effect.andThen(Scope.close(sessionScope, Exit.void).pipe(Effect.ignore))), + ); + const pendingApprovals = new Map(); + const stoppedSignal = yield* Deferred.make(); + const resumedSessionId = resumeSessionId(input.resumeCursor); + const modelSelection = + input.modelSelection?.instanceId === instanceId ? input.modelSelection : undefined; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + const acp = yield* makeVibeAcpRuntime({ + vibeSettings: settings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumedSessionId ? { resumeSessionId: resumedSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [{ name: "Authorization", value: mcpSession.authorizationHeader }], + }, + ], + } + : {}), + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + yield* acp.handleRequestPermission((params) => + callbackError( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoOption = selectAutoApprovedPermissionOption(params); + if (autoOption !== undefined) { + return { outcome: { outcome: "selected" as const, optionId: autoOption } }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* nextId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision }); + const live = sessions.get(input.threadId); + const turnId = live?.activeTurn?.id; + yield* publish( + makeAcpRequestOpenedEvent({ + stamp: yield* stamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "Mistral Vibe requests permission.", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* publish( + makeAcpRequestResolvedEvent({ + stamp: yield* stamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + decision: resolved, + }), + ); + const optionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return optionId + ? { outcome: { outcome: "selected" as const, optionId } } + : { outcome: { outcome: "cancelled" as const } }; + }), + ), + ); + + const started = yield* acp + .start() + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", cause), + ), + ); + const trust = yield* readVibeWorkspaceTrust(acp, cwd).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "trust/status", cause), + ), + ); + if (trust.trust_status === "untrusted" && settings.trustWorkspace) { + const decision = selectVibeTrustDecision(trust); + if (decision === undefined) { + // Vibe has no trust prompt outstanding, so there is nothing to + // answer — sending one anyway is rejected with `-32602 No workspace + // trust decision is available` and would take the whole session + // down. Start untrusted instead and let Vibe gate what it needs to. + yield* Effect.logWarning("Mistral Vibe workspace is untrusted and cannot be granted.", { + threadId: input.threadId, + cwd, + }); + } else { + const trusted = yield* trustVibeWorkspace(acp, { + cwd, + sessionId: started.sessionId, + decision, + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "trust/decision", cause), + ), + ); + if (trusted.trust_status === "untrusted") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Vibe declined to trust this workspace.", + }); + } + } + } + + yield* applyVibeSessionConfiguration({ + runtime: acp, + model: modelSelection?.model, + selections: modelSelection?.options, + mode: vibeModeForRuntimeMode(input.runtimeMode), + mapError: (cause, method) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: resolveVibeModelId(modelSelection?.model), + threadId: input.threadId, + resumeCursor: { schemaVersion: RESUME_VERSION, sessionId: started.sessionId }, + createdAt: now, + updatedAt: now, + }; + const ctx: VibeSessionContext = { + threadId: input.threadId, + scope: sessionScope, + acp, + pendingApprovals, + stoppedSignal, + session, + turns: [], + lastPlanFingerprint: undefined, + stopped: false, + }; + sessions.set(input.threadId, ctx); + + yield* Stream.runForEach(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + if (event._tag === "ModeChanged") return; + const activeTurn = ctx.activeTurn; + if (!activeTurn) return; + const turnId = activeTurn.id; + const eventStamp = yield* stamp(); + switch (event._tag) { + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* publish( + makeAcpAssistantItemEvent({ + stamp: eventStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: event.itemId, + lifecycle: + event._tag === "AssistantItemStarted" ? "item.started" : "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate(ctx, turnId, eventStamp, event.payload, event.rawPayload); + return; + case "ToolCallUpdated": + yield* publish( + makeAcpToolCallEvent({ + stamp: eventStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* publish( + makeAcpContentDeltaEvent({ + stamp: eventStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + } + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logError("Failed to process a Mistral Vibe notification.", { cause }), + ), + Effect.forkIn(sessionScope), + ); + sessionScopeTransferred = true; + + yield* publish({ + type: "session.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* publish({ + type: "session.state.changed", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Mistral Vibe ACP session ready" }, + }); + yield* publish({ + type: "thread.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + return session; + }).pipe(Effect.scoped); + + const startSession: VibeAdapterShape["startSession"] = (input) => + withThreadLock(input.threadId, startSessionUnlocked(input)); + + const resetActiveTurn = (ctx: VibeSessionContext, turn: VibeActiveTurn) => + Effect.gen(function* () { + if (ctx.stopped || sessions.get(ctx.threadId) !== ctx || ctx.activeTurn !== turn) { + return false; + } + delete ctx.activeTurn; + const { activeTurnId: _activeTurnId, ...ready } = ctx.session; + ctx.session = { + ...ready, + status: "ready", + updatedAt: yield* nowIso, + }; + return true; + }); + + const settleActiveTurn = ( + ctx: VibeSessionContext, + turn: VibeActiveTurn, + payload: Extract["payload"], + ) => + Effect.gen(function* () { + if (!(yield* resetActiveTurn(ctx, turn))) { + return false; + } + // Match Cursor/Grok: a turn that never published `turn.started` must not + // publish a terminal event either. When Stop lands during preparation the + // session is reset silently and the failed `sendTurn` is what the caller + // observes — no consumer ever saw the turn open. + if (!turn.started) { + return true; + } + yield* publish({ + type: "turn.completed", + ...(yield* stamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: turn.id, + payload, + }); + return true; + }); + + const settlePromptInFlight = ( + ctx: VibeSessionContext, + turn: VibeActiveTurn, + options?: { + readonly payload?: Extract["payload"]; + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + if (ctx.stopped || sessions.get(ctx.threadId) !== ctx) { + return false; + } + turn.promptsInFlight = options?.settleAllPrompts + ? 0 + : Math.max(0, turn.promptsInFlight - 1); + if (turn.promptsInFlight > 0 || ctx.activeTurn !== turn) { + return false; + } + if (!options?.payload) { + return turn.started ? false : yield* resetActiveTurn(ctx, turn); + } + return yield* settleActiveTurn(ctx, turn, options.payload); + }); + + /** + * Prepares a `sendTurn` under the per-thread lock. + * + * The adapter allows a concurrent `sendTurn` to steer into the active + * turn id (instead of opening a second turn). Both the active-turn + * decision and the session configuration happen inside the lock, so + * concurrent sends can neither both interleave configuration nor create + * inconsistent turn ids. The prompt request is issued after this lock is + * released because the ACP runtime serializes prompts for their full + * lifetime. + */ + const prepareAndStartTurn = (input: Parameters[0]) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const activeTurn = ctx.activeTurn; + // Match Cursor/Grok steering: an overlapping prompt reserves another + // slot on the current turn instead of opening a second turn. + const steeringTurn = activeTurn?.promptsInFlight ? activeTurn : undefined; + const turn = + steeringTurn ?? + ({ + id: TurnId.make(yield* nextId), + started: false, + interrupted: false, + promptsInFlight: 0, + } satisfies VibeActiveTurn); + turn.promptsInFlight += 1; + ctx.activeTurn = turn; + ctx.session = { + ...ctx.session, + status: steeringTurn === undefined ? "connecting" : "running", + activeTurnId: turn.id, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const modelSelection = + input.modelSelection?.instanceId === instanceId ? input.modelSelection : undefined; + yield* applyVibeSessionConfiguration({ + runtime: ctx.acp, + model: modelSelection?.model ?? ctx.session.model, + selections: modelSelection?.options, + mode: vibeModeForRuntimeMode(ctx.session.runtimeMode, input.interactionMode), + mapError: (cause, method) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + const text = input.input?.trim(); + const images = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const prompt: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...images, + ]; + if (prompt.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + if (turn.interrupted) { + // Release only this prompt's reservation (the `tapCause` below). + // Zeroing the whole turn would make a steer-superseded sibling + // prompt that is still live on the wire look idle, so the next + // sendTurn would open a second turn id and a targeted stop for + // this one would no-op. + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Mistral Vibe prompt was interrupted during preparation.", + }); + } + ctx.session = { + ...ctx.session, + status: "running", + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + if (steeringTurn === undefined) { + ctx.lastPlanFingerprint = undefined; + yield* publish({ + type: "turn.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: turn.id, + payload: { model: ctx.session.model }, + }); + turn.started = true; + } + return { ctx, turn, prompt }; + }).pipe(Effect.tapCause(() => settlePromptInFlight(ctx, turn))); + }), + ); + + const sendTurn: VibeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const { ctx, turn, prompt } = yield* prepareAndStartTurn(input); + const turnId = turn.id; + let promptSettled = false; + return yield* Effect.gen(function* () { + const result = yield* ctx.acp.prompt({ prompt }).pipe( + Effect.tapError((cause) => + Effect.gen(function* () { + yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(ctx, turn, { + payload: { + state: "failed", + errorMessage: cause.message || "Mistral Vibe prompt request failed.", + }, + }), + ); + promptSettled = true; + }).pipe(Effect.catchCause(() => Effect.void)), + ), + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), + ), + ); + yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + if (ctx.stopped || sessions.get(input.threadId) !== ctx || ctx.activeTurn !== turn) { + yield* settlePromptInFlight(ctx, turn); + return; + } + const turnRecord = ctx.turns.find((entry) => entry.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + } + yield* settlePromptInFlight(ctx, turn, { + payload: { + state: + turn.interrupted || result.stopReason === "cancelled" + ? "cancelled" + : "completed", + stopReason: turn.interrupted ? "cancelled" : (result.stopReason ?? null), + }, + }); + }), + ); + promptSettled = true; + return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; + }).pipe( + // A caller interrupt (client disconnect, request cancellation) or a + // defect must not leave the prompt reserved: the session would stay + // `running` forever and every later sendTurn would steer into a turn + // that can never settle. + Effect.ensuring( + Effect.suspend(() => + promptSettled + ? Effect.void + : withThreadLock( + input.threadId, + settlePromptInFlight(ctx, turn, { + payload: { state: "cancelled", stopReason: "cancelled" }, + }), + ), + ).pipe(Effect.catchCause(() => Effect.void)), + ), + ); + }); + + const interruptTurn: VibeAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) return; + const activeTurn = ctx.activeTurn; + if (!activeTurn || (turnId && turnId !== activeTurn.id)) return; + activeTurn.interrupted = true; + }); + const acpCancel = yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurn = ctx.activeTurn; + // A stop targeted at a specific turn must not outlive it: once that + // turn is gone the cancel below would land on whatever prompt the + // next sendTurn started after this lock was released. + if (turnId && turnId !== activeTurn?.id) { + return Option.none>(); + } + yield* Effect.forEach( + ctx.pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + if (activeTurn) { + yield* settlePromptInFlight(ctx, activeTurn, { + payload: { + state: "cancelled", + stopReason: "cancelled", + }, + settleAllPrompts: true, + }); + } + // Vibe owns the cancellation boundary (`interruptPromptOnCancel: + // false`), so `cancel` waits on the agent's reply. Run it after the + // lock is released — a slow agent must not block sendTurn/stopSession + // for this thread — but bind the runtime captured under the lock so a + // concurrent restart cannot redirect the cancel at a new session. + return Option.some( + ctx.acp.cancel.pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", cause), + ), + Effect.ignore, + ), + ); + }), + ); + yield* Option.match(acpCancel, { + onNone: () => Effect.void, + onSome: (cancelEffect) => cancelEffect, + }); + }); + + const respondToRequest: VibeAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: VibeAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Mistral Vibe has no pending user-input request '${requestId}'.`, + }); + }); + + const readThread: VibeAdapterShape["readThread"] = (threadId) => + Effect.map(requireSession(threadId), (ctx) => ({ threadId, turns: ctx.turns })); + const rollbackThread: VibeAdapterShape["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: "thread/rollback", + detail: "Mistral Vibe ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: VibeAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.suspend(() => Effect.flatMap(requireSession(threadId), stopContext)), + ); + const listSessions: VibeAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session }))); + const hasSession: VibeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); + const stopAll: VibeAdapterShape["stopAll"] = () => + Effect.forEach( + Array.from(sessions.values()), + (ctx) => + withThreadLock( + ctx.threadId, + Effect.suspend(() => + sessions.get(ctx.threadId) === ctx ? stopContext(ctx) : Effect.void, + ), + ), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + // Deliberately bypasses `stopAll`: a `sendTurn` holding a thread lock at + // shutdown must not stall adapter finalization. + Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }).pipe( + Effect.ignore, + Effect.tap(() => PubSub.shutdown(events)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromPubSub(events), + } satisfies VibeAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/VibeProvider.test.ts b/apps/server/src/provider/Layers/VibeProvider.test.ts new file mode 100644 index 0000000000000..163c4a72eb314 --- /dev/null +++ b/apps/server/src/provider/Layers/VibeProvider.test.ts @@ -0,0 +1,154 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { VibeSettings } 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 { + buildVibeCapabilities, + buildVibeModelsFromConfigOptions, + checkVibeProviderStatus, +} from "./VibeProvider.ts"; + +const decodeVibeSettings = Schema.decodeSync(VibeSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const configOptions = [ + { + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "mistral-medium-3.5", + options: [ + { value: "mistral-medium-3.5", name: "Mistral Medium 3.5" }, + { value: "devstral-small", name: "Devstral Small" }, + { value: "local", name: "Local" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config" as const, + type: "select" as const, + currentValue: "high", + options: [ + { value: "off", name: "Off" }, + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, +]; + +describe("VibeProvider", () => { + it("discovers every Vibe model and marks the live default", () => { + const models = buildVibeModelsFromConfigOptions(configOptions); + expect(models.map((model) => model.slug)).toEqual([ + "mistral-medium-3.5", + "devstral-small", + "local", + ]); + expect(models[0]?.isDefault).toBe(true); + expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 1)).toBe(true); + }); + + it("exposes Vibe thinking levels as a model option", () => { + expect(buildVibeCapabilities(configOptions)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + currentValue: "high", + options: [ + { id: "off", label: "Off" }, + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + ], + }, + ], + }), + ); + }); +}); + +it.layer(NodeServices.layer)("checkVibeProviderStatus", (it) => { + it.effect("does not infer authentication from successful ACP discovery", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-vibe-discovery-" }); + const vibePath = path.join(dir, "vibe-acp"); + yield* fs.writeFileString( + vibePath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "vibe 0.0.99\\n"', + " exit 0", + "fi", + "export T3_ACP_VIBE_CONFIG=1", + `exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(mockAgentPath)} "$@"`, + "", + ].join("\n"), + ); + yield* fs.chmod(vibePath, 0o755); + + return yield* checkVibeProviderStatus( + decodeVibeSettings({ enabled: true, binaryPath: vibePath }), + ); + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.models.map((model) => model.slug)).toContain("mistral-medium-3.5"); + }), + ); + + it.effect("does not claim authentication failed when ACP discovery fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-vibe-discovery-" }); + const vibePath = path.join(dir, "vibe-acp"); + yield* fs.writeFileString( + vibePath, + ["#!/bin/sh", 'printf "vibe 0.0.99\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(vibePath, 0o755); + + return yield* checkVibeProviderStatus( + decodeVibeSettings({ enabled: true, binaryPath: vibePath }), + ); + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).toContain("ACP discovery failed"); + expect(snapshot.message).not.toContain("vibe --setup"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/VibeProvider.ts b/apps/server/src/provider/Layers/VibeProvider.ts new file mode 100644 index 0000000000000..1c926d36aed1d --- /dev/null +++ b/apps/server/src/provider/Layers/VibeProvider.ts @@ -0,0 +1,334 @@ +import { + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, + type VibeSettings, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { makeVibeAcpRuntime, resolveVibeModelId } from "../acp/VibeAcpSupport.ts"; + +const VIBE_PRESENTATION = { + displayName: "Mistral Vibe", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const ACP_PROBE_TIMEOUT_MS = 15_000; + +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: "mistral-medium-3.5", + name: "Mistral Medium 3.5", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "devstral-small", + name: "Devstral Small", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "local", + name: "Local", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function flattenSelectOptions( + option: EffectAcpSchema.SessionConfigOption, +): ReadonlyArray<{ value: string; label: string }> { + if (option.type !== "select") return []; + return option.options.flatMap((entry) => + "value" in entry + ? [{ value: entry.value, label: entry.name }] + : entry.options.map((nested) => ({ value: nested.value, label: nested.name })), + ); +} + +export function buildVibeCapabilities( + configOptions: ReadonlyArray, +): ModelCapabilities { + const thinking = configOptions.find( + (option) => option.id.trim().toLowerCase() === "thinking" && option.type === "select", + ); + if (!thinking) return EMPTY_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: thinking.id, + label: thinking.name.trim() || "Thinking", + options: flattenSelectOptions(thinking).map((entry) => ({ + value: entry.value, + label: entry.label, + ...(thinking.currentValue === entry.value ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +export function buildVibeModelsFromConfigOptions( + configOptions: ReadonlyArray, +): ReadonlyArray { + const model = configOptions.find( + (option) => + (option.category === "model" || option.id.trim().toLowerCase() === "model") && + option.type === "select", + ); + if (!model) return []; + const capabilities = buildVibeCapabilities(configOptions); + const seen = new Set(); + return flattenSelectOptions(model).flatMap((entry) => { + const slug = resolveVibeModelId(entry.value); + if (seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: entry.label.trim() || slug, + isCustom: false, + ...(model.currentValue === entry.value ? { isDefault: true } : {}), + capabilities, + }, + ]; + }); +} + +function modelsFromSettings( + settings: VibeSettings, + builtIn: ReadonlyArray = FALLBACK_MODELS, +) { + return providerModelsFromSettings(builtIn, settings.customModels, EMPTY_CAPABILITIES); +} + +export function buildInitialVibeProviderSnapshot( + settings: VibeSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: modelsFromSettings(settings), + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Mistral Vibe availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Mistral Vibe is disabled in T3 Code settings.", + }, + }); + }); +} + +const runVersionCommand = (settings: VibeSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const command = settings.binaryPath || "vibe-acp"; + const spawn = yield* resolveSpawnCommand(command, ["--version"], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + }), + ); + }); + +const discoverViaAcp = (settings: VibeSettings, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeVibeAcpRuntime({ + vibeSettings: settings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + yield* acp.start(); + return buildVibeModelsFromConfigOptions(yield* acp.getConfigOptions); + }).pipe(Effect.scoped); + +export const checkVibeProviderStatus = Effect.fn("checkVibeProviderStatus")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = modelsFromSettings(settings); + if (!settings.enabled) { + return yield* buildInitialVibeProviderSnapshot(settings); + } + + const versionResult = yield* runVersionCommand(settings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + const missing = isCommandMissingCause(versionResult.failure); + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "Mistral Vibe (`vibe-acp`) is not installed or not on PATH." + : "Failed to execute the Mistral Vibe health check.", + }, + }); + } + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Mistral Vibe timed out while checking its version.", + }, + }); + } + + const output = versionResult.success.value; + const version = parseGenericCliVersion(`${output.stdout}\n${output.stderr}`); + if (output.code !== 0) { + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Mistral Vibe is installed but failed to run.", + }, + }); + } + + const discovery = yield* discoverViaAcp(settings, environment).pipe( + Effect.timeoutOption(ACP_PROBE_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discovery)) { + yield* Effect.logWarning("Mistral Vibe ACP discovery failed", { + errorTag: causeErrorTag(discovery.cause), + }); + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: + "Mistral Vibe is installed but ACP discovery failed. Check the Vibe configuration and server logs, then refresh.", + }, + }); + } + if (Option.isNone(discovery.value)) { + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Mistral Vibe ACP startup timed out.", + }, + }); + } + + const discovered = discovery.value.value; + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models: modelsFromSettings(settings, discovered.length > 0 ? discovered : FALLBACK_MODELS), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichVibeSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => + enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + Effect.catchCause((cause) => + Effect.logWarning("Mistral Vibe version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); diff --git a/apps/server/src/provider/Services/VibeAdapter.ts b/apps/server/src/provider/Services/VibeAdapter.ts new file mode 100644 index 0000000000000..557aeecde361e --- /dev/null +++ b/apps/server/src/provider/Services/VibeAdapter.ts @@ -0,0 +1,4 @@ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface VibeAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e59539..98744d10715fc 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -22,6 +22,38 @@ const mockAgentCommand = "node"; const mockAgentArgs = [mockAgentPath]; describe("AcpSessionRuntime", () => { + it.effect("does not authenticate when no auth method is configured", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const recordedRequests = NodeFS.readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method?: string }); + expect(recordedRequests.some((message) => message.method === "authenticate")).toBe(false); + expect(recordedRequests.some((message) => message.method === "session/new")).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }); + it.effect("merges custom initialize client capabilities into the ACP handshake", () => { const requestEvents: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d4..fe9b22ab6c271 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -61,6 +61,13 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + /** + * Whether cancelling should immediately interrupt the local prompt RPC. + * Disable this for agents whose prompt response is the authoritative + * cancellation boundary, so late notifications cannot bleed into a + * subsequent turn. + */ + readonly interruptPromptOnCancel?: boolean; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; @@ -68,7 +75,13 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP authentication method to invoke before opening the session. + * Omit this for agents such as Vibe that read persisted credentials during + * session creation: their interactive authenticate method opens a browser + * and must never run from background probes. + */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -541,15 +554,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId !== undefined) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -761,13 +776,17 @@ export const make = ( cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { - const activePromptFiber = yield* Ref.get(activePromptFiberRef); - if (Option.isSome(activePromptFiber)) { - yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + if (options.interruptPromptOnCancel !== false) { + const activePromptFiber = yield* Ref.get(activePromptFiberRef); + if (Option.isSome(activePromptFiber)) { + yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); + } + yield* acp.agent + .cancel({ sessionId: started.sessionId }) + .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + return; } - yield* acp.agent - .cancel({ sessionId: started.sessionId }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + yield* acp.agent.cancel({ sessionId: started.sessionId }); }), ), ), diff --git a/apps/server/src/provider/acp/VibeAcpSupport.test.ts b/apps/server/src/provider/acp/VibeAcpSupport.test.ts new file mode 100644 index 0000000000000..d5900093cda69 --- /dev/null +++ b/apps/server/src/provider/acp/VibeAcpSupport.test.ts @@ -0,0 +1,158 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + applyVibeSessionConfiguration, + buildVibeAcpSpawnInput, + buildVibeEnvironment, + readVibeWorkspaceTrust, + resolveVibeModelId, + trustVibeWorkspace, + vibeModeForRuntimeMode, +} from "./VibeAcpSupport.ts"; + +describe("VibeAcpSupport", () => { + it("spawns vibe-acp without interactive authentication arguments", () => { + expect(buildVibeAcpSpawnInput(undefined, "/tmp/project", { TEST: "1" })).toEqual({ + command: "vibe-acp", + args: [], + cwd: "/tmp/project", + env: { TEST: "1" }, + }); + }); + + it("overrides Vibe's Local preset with an Ollama-backed Devstral model", () => { + const environment = buildVibeEnvironment( + { + binaryPath: "vibe-acp", + ollamaEnabled: true, + ollamaBaseUrl: "http://127.0.0.1:11434/v1", + ollamaModel: "devstral-small-2", + }, + { KEEP_ME: "yes" }, + ); + expect(environment?.KEEP_ME).toBe("yes"); + expect(JSON.parse(environment?.VIBE_PROVIDERS ?? "[]")).toEqual([ + { + name: "local", + api_base: "http://127.0.0.1:11434/v1", + api_style: "openai", + backend: "generic", + api_key_env_var: "", + }, + ]); + expect(JSON.parse(environment?.VIBE_MODELS ?? "[]")).toEqual([ + { + name: "devstral-small-2", + provider: "local", + alias: "local", + input_price: 0, + output_price: 0, + }, + ]); + }); + + it("maps T3 runtime and interaction modes to Vibe modes", () => { + expect(vibeModeForRuntimeMode("approval-required")).toBe("default"); + expect(vibeModeForRuntimeMode("auto-accept-edits")).toBe("accept-edits"); + expect(vibeModeForRuntimeMode("auto")).toBe("accept-edits"); + expect(vibeModeForRuntimeMode("full-access")).toBe("auto-approve"); + expect(vibeModeForRuntimeMode("full-access", "plan")).toBe("plan"); + }); + + it.effect("uses ACP extension method names for Vibe workspace trust", () => + Effect.gen(function* () { + const calls: Array = []; + const runtime = { + request: (method: string, payload: unknown) => + Effect.sync(() => { + calls.push([method, payload]); + return { trust_status: "trusted", details: null }; + }), + }; + + yield* readVibeWorkspaceTrust(runtime, "/tmp/project"); + yield* trustVibeWorkspace(runtime, { + cwd: "/tmp/project", + sessionId: "session-1", + }); + + expect(calls).toEqual([ + ["_trust/status", { cwd: "/tmp/project" }], + [ + "_trust/decision", + { + cwd: "/tmp/project", + sessionId: "session-1", + decision: "trust_cwd", + }, + ], + ]); + }), + ); + + it.effect("applies model, thinking, and mode through config options", () => + Effect.gen(function* () { + const calls: Array = []; + const runtime = { + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "mistral-medium-3.5", + options: [ + { value: "mistral-medium-3.5", name: "Mistral Medium 3.5" }, + { value: "devstral-small", name: "Devstral Small" }, + ], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config" as const, + type: "select" as const, + currentValue: "high", + options: [ + { value: "off", name: "Off" }, + { value: "high", name: "High" }, + ], + }, + ]), + setModel: (value: string) => + Effect.sync(() => { + calls.push(["model", value]); + }), + setConfigOption: (id: string, value: string | boolean) => + Effect.sync(() => { + calls.push([id, value]); + }), + setMode: (value: string) => + Effect.sync(() => { + calls.push(["mode", value]); + return {}; + }), + }; + + yield* applyVibeSessionConfiguration({ + runtime, + model: "devstral-small", + selections: [ + { id: "thinking", value: "off" }, + { id: "unknown", value: "ignored" }, + ], + mode: "plan", + mapError: (cause: EffectAcpErrors.AcpError) => cause, + }); + expect(calls).toEqual([ + ["model", "devstral-small"], + ["thinking", "off"], + ["mode", "plan"], + ]); + expect(resolveVibeModelId(undefined)).toBe("mistral-medium-3.5"); + expect(ProviderDriverKind.make("vibe")).toBe("vibe"); + }), + ); +}); diff --git a/apps/server/src/provider/acp/VibeAcpSupport.ts b/apps/server/src/provider/acp/VibeAcpSupport.ts new file mode 100644 index 0000000000000..ad7229ec3d5a6 --- /dev/null +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -0,0 +1,256 @@ +import { + type ProviderOptionSelection, + type ProviderInteractionMode, + type RuntimeMode, + type VibeSettings, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; + +type VibeAcpRuntimeSettings = Pick< + VibeSettings, + "binaryPath" | "ollamaBaseUrl" | "ollamaEnabled" | "ollamaModel" +>; + +export interface VibeAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly vibeSettings: VibeAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +const WorkspaceTrustStatusResponse = Schema.Struct({ + trust_status: Schema.Literals(["trusted", "session", "untrusted"]), + details: Schema.NullOr( + Schema.Struct({ + cwd: Schema.String, + repoRoot: Schema.NullOr(Schema.String), + ignoredFiles: Schema.Array(Schema.String), + availableDecisions: Schema.Array( + Schema.Literals(["trust_repo", "trust_cwd", "trust_session", "decline"]), + ), + }), + ), +}); +export type WorkspaceTrustStatusResponse = typeof WorkspaceTrustStatusResponse.Type; +const decodeWorkspaceTrustStatusResponse = Schema.decodeUnknownEffect(WorkspaceTrustStatusResponse); + +export type VibeWorkspaceTrustDecision = "trust_repo" | "trust_cwd" | "trust_session"; + +/** + * Narrowest durable grant first. + * + * An automatic grant should never be wider than the workspace the user opened: + * `trust_repo` would trust every sibling directory in the repo off the back of + * a session in one subdirectory. `trust_cwd` matches what the adapter granted + * before this preference existed; `trust_session` is last because it buys + * nothing beyond the current session. + */ +const TRUST_DECISION_PREFERENCE: ReadonlyArray = [ + "trust_cwd", + "trust_repo", + "trust_session", +]; + +/** + * Picks the decision to answer an outstanding Vibe trust prompt with. + * + * Vibe only accepts `_trust/decision` while it has a decision pending — it + * reports the acceptable ones in `details.availableDecisions` and rejects + * anything else with `-32602 No workspace trust decision is available`. + * `details` is null when nothing is pending, which is not an error: the + * workspace simply cannot be auto-trusted from here. + */ +export function selectVibeTrustDecision( + status: WorkspaceTrustStatusResponse, +): VibeWorkspaceTrustDecision | undefined { + const available = status.details?.availableDecisions; + if (!available || available.length === 0) return undefined; + return TRUST_DECISION_PREFERENCE.find((decision) => available.includes(decision)); +} + +export function buildVibeAcpSpawnInput( + vibeSettings: VibeAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const env = buildVibeEnvironment(vibeSettings, environment); + return { + command: vibeSettings?.binaryPath || "vibe-acp", + args: [], + cwd, + ...(env ? { env } : {}), + }; +} + +export function buildVibeEnvironment( + settings: VibeAcpRuntimeSettings | null | undefined, + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv | undefined { + if (!settings?.ollamaEnabled) return environment; + const apiBase = settings.ollamaBaseUrl.trim() || "http://127.0.0.1:11434/v1"; + const model = settings.ollamaModel.trim() || "devstral-small-2"; + return { + ...environment, + VIBE_PROVIDERS: JSON.stringify([ + { + name: "local", + api_base: apiBase, + api_style: "openai", + backend: "generic", + api_key_env_var: "", + }, + ]), + VIBE_MODELS: JSON.stringify([ + { + name: model, + provider: "local", + alias: "local", + input_price: 0, + output_price: 0, + }, + ]), + }; +} + +export const makeVibeAcpRuntime = ( + input: VibeAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + interruptPromptOnCancel: false, + spawn: buildVibeAcpSpawnInput(input.vibeSettings, input.cwd, input.environment), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveVibeModelId(model: string | null | undefined): string { + return model?.trim() || DEFAULT_VIBE_MODEL; +} + +export function vibeModeForRuntimeMode( + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode = "default", +): string { + if (interactionMode === "plan") return "plan"; + switch (runtimeMode) { + case "approval-required": + return "default"; + case "auto-accept-edits": + case "auto": + return "accept-edits"; + case "full-access": + return "auto-approve"; + } +} + +export function applyVibeSessionConfiguration(input: { + readonly runtime: { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setMode: (modeId: string) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; + }; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mode: string; + readonly mapError: (cause: EffectAcpErrors.AcpError, method: string) => E; +}): Effect.Effect { + return Effect.gen(function* () { + yield* input.runtime + .setModel(resolveVibeModelId(input.model)) + .pipe(Effect.mapError((cause) => input.mapError(cause, "session/set_config_option:model"))); + + const configOptions = yield* input.runtime.getConfigOptions; + const validConfigIds = new Set(configOptions.map((option) => option.id)); + for (const selection of input.selections ?? []) { + if (selection.id === "model" || !validConfigIds.has(selection.id)) continue; + yield* input.runtime + .setConfigOption(selection.id, selection.value) + .pipe( + Effect.mapError((cause) => + input.mapError(cause, `session/set_config_option:${selection.id}`), + ), + ); + } + + yield* input.runtime + .setMode(input.mode) + .pipe(Effect.mapError((cause) => input.mapError(cause, "session/set_config_option:mode"))); + }); +} + +export const readVibeWorkspaceTrust = ( + runtime: Pick, + cwd: string, +): Effect.Effect => + runtime.request("_trust/status", { cwd }).pipe( + Effect.flatMap((response) => + decodeWorkspaceTrustStatusResponse(response).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Vibe returned an invalid workspace trust response: ${cause.message}`, + data: cause, + }), + ), + ), + ), + ); + +export const trustVibeWorkspace = ( + runtime: Pick, + input: { + readonly cwd: string; + readonly sessionId: string; + readonly decision?: VibeWorkspaceTrustDecision; + }, +): Effect.Effect => + runtime + .request("_trust/decision", { + cwd: input.cwd, + sessionId: input.sessionId, + decision: input.decision ?? "trust_cwd", + }) + .pipe( + Effect.flatMap((response) => + decodeWorkspaceTrustStatusResponse(response).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Vibe returned an invalid workspace trust response: ${cause.message}`, + data: cause, + }), + ), + ), + ), + ); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c1..2af83c64cd565 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ 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 { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { VibeDriver, type VibeDriverEnv } from "./Drivers/VibeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | VibeDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray): string { + const binaryPath = NodePath.join(dir, "vibe-acp"); + NodeFS.writeFileSync( + binaryPath, + [ + "#!/bin/sh", + "export T3_ACP_VIBE_CONFIG=1", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@"`, + "", + ].join("\n"), + "utf8", + ); + NodeFS.chmodSync(binaryPath, 0o755); + return binaryPath; +} + +it.layer(NodeServices.layer)("VibeTextGeneration", (it) => { + it.effect("uses Vibe's valid default mode for headless text generation", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-vibe-text-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const binaryPath = makeVibeWrapper(tempDir, { + T3_ACP_PROMPT_RESPONSE_TEXT: '{"title":"Add Mistral Vibe support"}', + }); + const textGeneration = yield* makeVibeTextGeneration(decodeVibeSettings({ binaryPath })); + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "support Mistral Vibe through ACP", + modelSelection: createModelSelection(ProviderInstanceId.make("vibe"), "mistral-medium-3.5"), + }); + + expect(generated.title).toBe("Add Mistral Vibe support"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/textGeneration/VibeTextGeneration.ts b/apps/server/src/textGeneration/VibeTextGeneration.ts new file mode 100644 index 0000000000000..b77a2ab0fe4c8 --- /dev/null +++ b/apps/server/src/textGeneration/VibeTextGeneration.ts @@ -0,0 +1,227 @@ +import { type ModelSelection, TextGenerationError, type VibeSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + applyVibeSessionConfiguration, + makeVibeAcpRuntime, +} from "../provider/acp/VibeAcpSupport.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const TIMEOUT_MS = 180_000; +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(function* ( + settings: VibeSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runJson = (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const output = yield* Ref.make(""); + const runtime = yield* makeVibeAcpRuntime({ + vibeSettings: settings, + environment: resolvedEnvironment, + childProcessSpawner: spawner, + cwd: input.cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") return Effect.void; + const content = update.content; + if (content.type !== "text") return Effect.void; + return Ref.update(output, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* applyVibeSessionConfiguration({ + runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mode: "default", + mapError: (cause, method) => + new TextGenerationError({ + operation: input.operation, + detail: `Failed to configure Mistral Vibe through ${method}.`, + cause, + }), + }); + return yield* runtime.prompt({ prompt: [{ type: "text", text: input.prompt }] }); + }).pipe( + Effect.timeoutOption(TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "Mistral Vibe request timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: + "Mistral Vibe ACP request failed. Run `vibe --setup` if authentication is incomplete.", + cause, + }), + ), + ); + + const raw = (yield* Ref.get(output)).trim(); + if (!raw) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: + promptResult.stopReason === "cancelled" + ? "Mistral Vibe request was cancelled." + : "Mistral Vibe returned empty output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(raw)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "Mistral Vibe returned invalid structured output.", + cause, + }), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: "Mistral Vibe ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("VibeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runJson({ + 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("VibeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runJson({ + 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("VibeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("VibeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588e..65093f0648a9c 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,17 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const MistralIcon: 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.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a70071681..39277bcd700b5 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, MistralIcon, 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("vibe")]: MistralIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d68075..73cd25ebe4baa 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -5,9 +5,18 @@ import { GrokSettings, OpenCodeSettings, ProviderDriverKind, + VibeSettings, } 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, + MistralIcon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("vibe"), + label: "Mistral Vibe", + icon: MistralIcon, + badgeLabel: "Early Access", + settingsSchema: VibeSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2d0d7bb02134e..424326ebd0879 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -9,6 +9,7 @@ import { resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, } from "./providerInstances"; function provider(input: { @@ -132,6 +133,28 @@ describe("deriveProviderInstanceEntries", () => { }); }); +describe("sortProviderInstanceEntries", () => { + it("places Mistral Vibe immediately after OpenCode while preserving instances within each kind", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + provider({ provider: ProviderDriverKind.make("vibe"), instanceId: "vibe_personal" }), + provider({ provider: ProviderDriverKind.make("vibe"), instanceId: "vibe" }), + provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" }), + provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode_personal" }), + provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + ]); + + expect(sortProviderInstanceEntries(entries).map((entry) => entry.instanceId)).toEqual([ + "claudeAgent", + "grok", + "opencode", + "opencode_personal", + "vibe", + "vibe_personal", + ]); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("returns the requested instance when it is enabled and available", () => { const requested = ProviderInstanceId.make("claude_work"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0af..d4e96f3101592 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -202,11 +202,12 @@ export function applyProviderInstanceSettings( return entries.map((entry) => { const explicitInstance = settings.providerInstances?.[entry.instanceId]; - const enabled = explicitInstance - ? (explicitInstance.enabled ?? true) - : entry.isDefault - ? (legacyProviders[entry.driverKind]?.enabled ?? entry.enabled) - : false; + let enabled = false; + if (explicitInstance) { + enabled = explicitInstance.enabled ?? true; + } else if (entry.isDefault) { + enabled = legacyProviders[entry.driverKind]?.enabled ?? entry.enabled; + } return enabled === entry.enabled ? entry : { ...entry, enabled }; }); } @@ -215,8 +216,8 @@ export function applyProviderInstanceSettings( * Sort instance entries so the default instance of each driver kind appears * before any custom instances of the same kind. Within a kind, custom * instances keep their settings-author order (which is how the server - * emits them). Stable across kinds: entries retain the server's - * cross-driver ordering. + * emits them). Across kinds, entries retain the server's order except that + * Mistral Vibe is kept immediately after OpenCode to match provider settings. */ export function sortProviderInstanceEntries( entries: ReadonlyArray, @@ -234,8 +235,19 @@ export function sortProviderInstanceEntries( byKind.set(entry.driverKind, [entry]); } } + const orderedBuckets = Array.from(byKind.entries()); + const vibeIndex = orderedBuckets.findIndex(([kind]) => kind === "vibe"); + const openCodeIndex = orderedBuckets.findIndex(([kind]) => kind === "opencode"); + if (vibeIndex !== -1 && openCodeIndex !== -1 && vibeIndex !== openCodeIndex + 1) { + const [vibeBucket] = orderedBuckets.splice(vibeIndex, 1); + if (vibeBucket) { + const currentOpenCodeIndex = orderedBuckets.findIndex(([kind]) => kind === "opencode"); + orderedBuckets.splice(currentOpenCodeIndex + 1, 0, vibeBucket); + } + } + const sorted: ProviderInstanceEntry[] = []; - for (const bucket of byKind.values()) { + for (const [, bucket] of orderedBuckets) { const defaults = bucket.filter((entry) => entry.isDefault); const customs = bucket.filter((entry) => !entry.isDefault); sorted.push(...defaults, ...customs); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e7d..b54e07a488a23 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("vibe"), + label: "Mistral Vibe", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 46067917f1360..8e6489931e473 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 VIBE_DRIVER_KIND = ProviderDriverKind.make("vibe"); 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", + [VIBE_DRIVER_KIND]: "Mistral Vibe", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2bc61d72f2194..b4405f0078167 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -122,6 +122,7 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.vibe.trustWorkspace).toBe(true); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5cf..f05f312cc3985 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -351,6 +351,72 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const VibeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("vibe-acp").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Mistral Vibe ACP binary.", + providerSettingsForm: { placeholder: "vibe-acp", clearWhenEmpty: "omit" }, + }), + ), + trustWorkspace: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Trust workspaces", + description: + "Automatically trust projects opened in T3 Code so Vibe can load repository instructions such as AGENTS.md. Trust is persisted by Vibe for each workspace.", + providerSettingsForm: { control: "switch" }, + }), + ), + ollamaEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Use a local model", + description: + "Point Vibe's Local model option at an OpenAI-compatible server such as vLLM, Ollama, LM Studio, or llama.cpp.", + providerSettingsForm: { control: "switch" }, + }), + ), + ollamaBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://127.0.0.1:11434/v1")), + Schema.annotateKey({ + title: "Local API URL", + description: + "OpenAI-compatible endpoint. Vibe commonly uses port 8080; Ollama normally uses port 11434.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:11434/v1", + clearWhenEmpty: "omit", + }, + }), + ), + ollamaModel: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("devstral-small-2")), + Schema.annotateKey({ + title: "Local model ID", + description: + "Model ID exposed by your server, for example mistralai/Devstral-Small-2-24B-Instruct-2512 or an Ollama model name.", + providerSettingsForm: { + placeholder: "devstral-small-2", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "trustWorkspace", "ollamaEnabled", "ollamaBaseUrl", "ollamaModel"], + }, +); +export type VibeSettings = typeof VibeSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -519,6 +585,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({}))), + vibe: VibeSettings.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 @@ -615,6 +682,16 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const VibeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + trustWorkspace: Schema.optionalKey(Schema.Boolean), + ollamaEnabled: Schema.optionalKey(Schema.Boolean), + ollamaBaseUrl: Schema.optionalKey(TrimmedString), + ollamaModel: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -662,6 +739,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + vibe: Schema.optionalKey(VibeSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),