From 0f6804e610ce453110ae45340824360b9d7fb897 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 10:02:51 +0200 Subject: [PATCH 01/20] [FEATURE] Mistral Vibe support --- .../server/src/provider/Drivers/VibeDriver.ts | 158 ++++ .../ProviderInstanceRegistryLive.test.ts | 43 +- .../server/src/provider/Layers/VibeAdapter.ts | 695 ++++++++++++++++++ .../src/provider/Layers/VibeProvider.test.ts | 66 ++ .../src/provider/Layers/VibeProvider.ts | 334 +++++++++ .../src/provider/Services/VibeAdapter.ts | 4 + .../provider/acp/AcpJsonRpcConnection.test.ts | 32 + .../src/provider/acp/AcpSessionRuntime.ts | 26 +- .../src/provider/acp/VibeAcpSupport.test.ts | 158 ++++ .../server/src/provider/acp/VibeAcpSupport.ts | 217 ++++++ apps/server/src/provider/builtInDrivers.ts | 5 +- .../src/textGeneration/TextGeneration.ts | 8 +- .../src/textGeneration/VibeTextGeneration.ts | 227 ++++++ apps/web/src/components/Icons.tsx | 11 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 4 + packages/contracts/src/settings.ts | 76 ++ 19 files changed, 2073 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/provider/Drivers/VibeDriver.ts create mode 100644 apps/server/src/provider/Layers/VibeAdapter.ts create mode 100644 apps/server/src/provider/Layers/VibeProvider.test.ts create mode 100644 apps/server/src/provider/Layers/VibeProvider.ts create mode 100644 apps/server/src/provider/Services/VibeAdapter.ts create mode 100644 apps/server/src/provider/acp/VibeAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/VibeAcpSupport.ts create mode 100644 apps/server/src/textGeneration/VibeTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/VibeDriver.ts b/apps/server/src/provider/Drivers/VibeDriver.ts new file mode 100644 index 0000000000000..11ef64fa82f28 --- /dev/null +++ b/apps/server/src/provider/Drivers/VibeDriver.ts @@ -0,0 +1,158 @@ +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 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, + 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 384de852f9b53..5e71e98b8566a 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 Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -45,6 +46,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"; @@ -90,6 +92,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", @@ -258,12 +271,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 = { @@ -294,6 +309,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", @@ -303,7 +324,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, VibeDriver, OpenCodeDriver], configMap, }); @@ -313,9 +334,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 — @@ -325,16 +346,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 @@ -347,6 +371,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + vibe!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -355,6 +380,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + vibe!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -363,6 +389,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + vibe!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -399,6 +426,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/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts new file mode 100644 index 0000000000000..ebcb179c8e1f7 --- /dev/null +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -0,0 +1,695 @@ +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 Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +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 { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + applyVibeSessionConfiguration, + makeVibeAcpRuntime, + readVibeWorkspaceTrust, + resolveVibeModelId, + trustVibeWorkspace, + vibeModeForRuntimeMode, +} from "../acp/VibeAcpSupport.ts"; +import type { VibeAdapterShape } from "../Services/VibeAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("vibe"); +const RESUME_VERSION = 1 as const; + +export interface VibeAdapterOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface VibeSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly pendingApprovals: Map; + session: ProviderSession; + turns: Array<{ id: TurnId; items: Array }>; + notificationFiber?: Fiber.Fiber; + activeTurnId?: TurnId; + stopped: boolean; +} + +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; +} + +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 sessions = 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 callbackError = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process a Mistral Vibe ACP callback.", + cause, + }), + ), + ); + + const stopContext = (ctx: VibeSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* Effect.forEach( + ctx.pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + if (ctx.notificationFiber) yield* Fiber.interrupt(ctx.notificationFiber); + 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 startSession: 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.", + }); + } + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) return existing.session; + + const cwd = path.resolve(input.cwd); + const sessionScope = yield* Scope.make(); + 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 resumedSessionId = resumeSessionId(input.resumeCursor); + const modelSelection = + input.modelSelection?.instanceId === instanceId ? input.modelSelection : undefined; + const mcpSession = McpProviderSession.readMcpProviderSession(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" }, + ...(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* () { + if (input.runtimeMode === "full-access") { + const autoOption = + selectPermissionOptionId(params, "acceptForSession") ?? + selectPermissionOptionId(params, "accept"); + if (autoOption) { + 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?.activeTurnId; + yield* publish( + makeAcpRequestOpenedEvent({ + stamp: yield* stamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + detail: permissionRequest.detail ?? "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") { + if (!settings.trustWorkspace) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: + "Vibe has not trusted this workspace, so repository instructions are ignored. Enable “Trust workspaces” for this Vibe instance or trust it from the Vibe CLI.", + }); + } + const trusted = yield* trustVibeWorkspace(acp, { + cwd, + sessionId: started.sessionId, + }).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, + acpSessionId: started.sessionId, + scope: sessionScope, + acp, + pendingApprovals, + session, + turns: [], + stopped: false, + }; + sessions.set(input.threadId, ctx); + + ctx.notificationFiber = yield* Stream.runForEach(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if (event._tag === "ModeChanged") return; + const turnId = ctx.activeTurnId; + if (!turnId) return; + 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* publish( + makeAcpPlanUpdatedEvent({ + stamp: eventStamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: 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 sendTurn: VibeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Mistral Vibe already has an active turn.", + }); + } + 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.", + }); + } + + const turnId = TurnId.make(yield* nextId); + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + yield* publish({ + type: "turn.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + + const result = yield* ctx.acp.prompt({ prompt }).pipe( + Effect.tapError((cause) => + Effect.gen(function* () { + yield* ctx.acp.drainEvents; + if (ctx.activeTurnId !== turnId) return; + delete ctx.activeTurnId; + const { activeTurnId: _activeTurnId, ...ready } = ctx.session; + ctx.session = { + ...ready, + status: "ready", + updatedAt: yield* nowIso, + }; + yield* publish({ + type: "turn.completed", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "failed", + errorMessage: cause.message || "Mistral Vibe prompt request failed.", + }, + }); + }).pipe(Effect.catchCause(() => Effect.void)), + ), + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), + ), + ); + yield* ctx.acp.drainEvents; + ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + const updatedAt = yield* nowIso; + delete ctx.activeTurnId; + const { activeTurnId: _activeTurnId, ...ready } = ctx.session; + ctx.session = { ...ready, status: "ready", updatedAt }; + yield* publish({ + type: "turn.completed", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason, + }, + }); + return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; + }); + + const interruptTurn: VibeAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId; + if (turnId && activeTurnId && turnId !== activeTurnId) return; + yield* ctx.acp.cancel.pipe(Effect.ignore); + yield* Effect.forEach( + ctx.pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + if (activeTurnId) { + delete ctx.activeTurnId; + const { activeTurnId: _activeTurnId, ...ready } = ctx.session; + ctx.session = { ...ready, status: "ready", updatedAt: yield* nowIso }; + yield* publish({ + type: "turn.completed", + ...(yield* stamp()), + provider: PROVIDER, + threadId, + turnId: activeTurnId, + payload: { state: "cancelled", stopReason: "cancelled" }, + }); + } + }); + + 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) => + Effect.flatMap(requireSession(threadId), stopContext); + const listSessions: VibeAdapterShape["listSessions"] = () => + Effect.succeed(Array.from(sessions.values(), (ctx) => ctx.session)); + const hasSession: VibeAdapterShape["hasSession"] = (threadId) => + Effect.succeed(sessions.has(threadId)); + const stopAll: VibeAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }); + + 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..c78f16c060fec --- /dev/null +++ b/apps/server/src/provider/Layers/VibeProvider.test.ts @@ -0,0 +1,66 @@ +import { createModelCapabilities } from "@t3tools/shared/model"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildVibeCapabilities, buildVibeModelsFromConfigOptions } from "./VibeProvider.ts"; + +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" }, + ], + }, + ], + }), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/VibeProvider.ts b/apps/server/src/provider/Layers/VibeProvider.ts new file mode 100644 index 0000000000000..242f8dd2c2e42 --- /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: "unauthenticated" }, + message: + "Mistral Vibe is installed but ACP could not open a session. Run `vibe --setup` to authenticate, 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: "authenticated", type: "Mistral account" }, + }, + }); +}); + +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..caafbf9b7ed67 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,7 +68,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 +547,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: 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..7b6f7ff83439d --- /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: "t3-ollama", + 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: "t3-ollama", + 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..1d82c9448bc27 --- /dev/null +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -0,0 +1,217 @@ +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 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: "t3-ollama", + api_base: apiBase, + api_style: "openai", + backend: "generic", + api_key_env_var: "", + }, + ]), + VIBE_MODELS: JSON.stringify([ + { + name: model, + provider: "t3-ollama", + 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, + 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 }, +): Effect.Effect => + runtime + .request("_trust/decision", { + cwd: input.cwd, + sessionId: input.sessionId, + 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(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: "chat", + 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/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.ts b/packages/contracts/src/settings.ts index 8266ad3337f85..92cfc58879675 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -345,6 +345,70 @@ 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(false)), + Schema.annotateKey({ + title: "Trust workspaces", + description: + "Allow Vibe to 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 Ollama for Local", + description: "Point Vibe's Local model option at an Ollama OpenAI-compatible endpoint.", + providerSettingsForm: { control: "switch" }, + }), + ), + ollamaBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://127.0.0.1:11434/v1")), + Schema.annotateKey({ + title: "Ollama API URL", + description: "OpenAI-compatible Ollama endpoint. Usually ends in /v1.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:11434/v1", + clearWhenEmpty: "omit", + }, + }), + ), + ollamaModel: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("devstral-small-2")), + Schema.annotateKey({ + title: "Ollama model", + description: + "Installed Ollama model used by Vibe's Local option, for example devstral-small-2 or devstral-2.", + 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( @@ -462,6 +526,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 @@ -558,6 +623,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), @@ -595,6 +670,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), }), ), From 002c5e39f2ef0fb21ef9cfd769fc8b0ac60a17ac Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 10:22:17 +0200 Subject: [PATCH 02/20] [FIX] Mistral on the bottom in provider dropdown --- apps/web/src/providerInstances.test.ts | 23 +++++++++++++++++++++ apps/web/src/providerInstances.ts | 28 ++++++++++++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) 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); From bc4107c08df2545a3d842606ff4d4220f0055113 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 10:59:29 +0200 Subject: [PATCH 03/20] [[FIX] Model registry for local --- .../src/provider/acp/VibeAcpSupport.test.ts | 4 ++-- apps/server/src/provider/acp/VibeAcpSupport.ts | 4 ++-- packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 18 ++++++++++-------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/acp/VibeAcpSupport.test.ts b/apps/server/src/provider/acp/VibeAcpSupport.test.ts index 7b6f7ff83439d..d5900093cda69 100644 --- a/apps/server/src/provider/acp/VibeAcpSupport.test.ts +++ b/apps/server/src/provider/acp/VibeAcpSupport.test.ts @@ -36,7 +36,7 @@ describe("VibeAcpSupport", () => { expect(environment?.KEEP_ME).toBe("yes"); expect(JSON.parse(environment?.VIBE_PROVIDERS ?? "[]")).toEqual([ { - name: "t3-ollama", + name: "local", api_base: "http://127.0.0.1:11434/v1", api_style: "openai", backend: "generic", @@ -46,7 +46,7 @@ describe("VibeAcpSupport", () => { expect(JSON.parse(environment?.VIBE_MODELS ?? "[]")).toEqual([ { name: "devstral-small-2", - provider: "t3-ollama", + provider: "local", alias: "local", input_price: 0, output_price: 0, diff --git a/apps/server/src/provider/acp/VibeAcpSupport.ts b/apps/server/src/provider/acp/VibeAcpSupport.ts index 1d82c9448bc27..505b554fe18bd 100644 --- a/apps/server/src/provider/acp/VibeAcpSupport.ts +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -71,7 +71,7 @@ export function buildVibeEnvironment( ...environment, VIBE_PROVIDERS: JSON.stringify([ { - name: "t3-ollama", + name: "local", api_base: apiBase, api_style: "openai", backend: "generic", @@ -81,7 +81,7 @@ export function buildVibeEnvironment( VIBE_MODELS: JSON.stringify([ { name: model, - provider: "t3-ollama", + provider: "local", alias: "local", input_price: 0, output_price: 0, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ed864344e35e0..e72cc02d2fd8f 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -104,6 +104,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 92cfc58879675..592edd82e9b63 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -359,27 +359,29 @@ export const VibeSettings = makeProviderSettingsSchema( }), ), trustWorkspace: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ title: "Trust workspaces", description: - "Allow Vibe to load repository instructions such as AGENTS.md. Trust is persisted by Vibe for each workspace.", + "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 Ollama for Local", - description: "Point Vibe's Local model option at an Ollama OpenAI-compatible endpoint.", + 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: "Ollama API URL", - description: "OpenAI-compatible Ollama endpoint. Usually ends in /v1.", + 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", @@ -389,9 +391,9 @@ export const VibeSettings = makeProviderSettingsSchema( ollamaModel: TrimmedString.pipe( Schema.withDecodingDefault(Effect.succeed("devstral-small-2")), Schema.annotateKey({ - title: "Ollama model", + title: "Local model ID", description: - "Installed Ollama model used by Vibe's Local option, for example devstral-small-2 or devstral-2.", + "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", From da755c16cea6ac5a09a35b94bb752ec60ab0b0b5 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 11:51:16 +0200 Subject: [PATCH 04/20] [FIX] Stale session, inallid auth state and losing turn --- apps/server/scripts/acp-mock-agent.ts | 106 +++++++++++--- .../src/provider/Layers/VibeAdapter.test.ts | 134 ++++++++++++++++++ .../server/src/provider/Layers/VibeAdapter.ts | 22 ++- .../src/provider/Layers/VibeProvider.test.ts | 46 +++++- .../src/provider/Layers/VibeProvider.ts | 4 +- 5 files changed, 283 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/provider/Layers/VibeAdapter.test.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd85474..0fe6ff3873f73 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -21,6 +21,7 @@ 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 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"; @@ -47,8 +48,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"; @@ -94,6 +95,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 +295,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 +331,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 +437,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}`, { @@ -878,6 +934,10 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "_trust/status" || method === "_trust/decision") { + 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/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts new file mode 100644 index 0000000000000..a229e38c915d0 --- /dev/null +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -0,0 +1,134 @@ +// @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, ThreadId, TurnId, VibeSettings } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { ServerConfig } from "../../config.ts"; +import { makeVibeAdapter, vibePromptSettlementBelongsToTurn } 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) => + makeVibeAdapter(decodeVibeSettings({ binaryPath })).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); +} + +it("requires a prompt settlement to match the active Vibe turn", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse(vibePromptSettlementBelongsToTurn(undefined, staleTurnId)); + assert.isFalse(vibePromptSettlementBelongsToTurn(replacementTurnId, staleTurnId)); + assert.isTrue(vibePromptSettlementBelongsToTurn(staleTurnId, staleTurnId)); +}); + +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("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 index ebcb179c8e1f7..71cc60456db61 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -81,6 +81,13 @@ interface VibeSessionContext { stopped: boolean; } +export function vibePromptSettlementBelongsToTurn( + activeTurnId: TurnId | undefined, + turnId: TurnId, +): boolean { + return activeTurnId === turnId; +} + function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, @@ -189,7 +196,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }); } const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) return existing.session; + if (existing && !existing.stopped) { + yield* stopContext(existing); + } const cwd = path.resolve(input.cwd); const sessionScope = yield* Scope.make(); @@ -548,7 +557,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt Effect.tapError((cause) => Effect.gen(function* () { yield* ctx.acp.drainEvents; - if (ctx.activeTurnId !== turnId) return; + if (!vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) return; delete ctx.activeTurnId; const { activeTurnId: _activeTurnId, ...ready } = ctx.session; ctx.session = { @@ -575,6 +584,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ); yield* ctx.acp.drainEvents; ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + if (!vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { + return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; + } const updatedAt = yield* nowIso; delete ctx.activeTurnId; const { activeTurnId: _activeTurnId, ...ready } = ctx.session; @@ -675,6 +687,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const stopAll: VibeAdapterShape["stopAll"] = () => Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }); + yield* Effect.addFinalizer(() => + Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }).pipe( + Effect.ignore, + ), + ); + return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, diff --git a/apps/server/src/provider/Layers/VibeProvider.test.ts b/apps/server/src/provider/Layers/VibeProvider.test.ts index c78f16c060fec..ee72f4349fe10 100644 --- a/apps/server/src/provider/Layers/VibeProvider.test.ts +++ b/apps/server/src/provider/Layers/VibeProvider.test.ts @@ -1,7 +1,19 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { describe, expect, it } from "vite-plus/test"; +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 } from "./VibeProvider.ts"; +import { + buildVibeCapabilities, + buildVibeModelsFromConfigOptions, + checkVibeProviderStatus, +} from "./VibeProvider.ts"; + +const decodeVibeSettings = Schema.decodeSync(VibeSettings); const configOptions = [ { @@ -64,3 +76,33 @@ describe("VibeProvider", () => { ); }); }); + +it.layer(NodeServices.layer)("checkVibeProviderStatus", (it) => { + 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 index 242f8dd2c2e42..e88c481da760d 100644 --- a/apps/server/src/provider/Layers/VibeProvider.ts +++ b/apps/server/src/provider/Layers/VibeProvider.ts @@ -276,9 +276,9 @@ export const checkVibeProviderStatus = Effect.fn("checkVibeProviderStatus")(func installed: true, version, status: "warning", - auth: { status: "unauthenticated" }, + auth: { status: "unknown" }, message: - "Mistral Vibe is installed but ACP could not open a session. Run `vibe --setup` to authenticate, then refresh.", + "Mistral Vibe is installed but ACP discovery failed. Check the Vibe configuration and server logs, then refresh.", }, }); } From 4770caecfe983ada922c0d94cd5a770aca17db54 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 11:59:37 +0200 Subject: [PATCH 05/20] [FIX] stopcontext, drainevent now makes it no op when stopped --- .../src/provider/Layers/VibeAdapter.test.ts | 43 +++++++++++++++++++ .../server/src/provider/Layers/VibeAdapter.ts | 15 ++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index a229e38c915d0..91b5d9032b9e4 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -9,6 +9,7 @@ import { ProviderDriverKind, ThreadId, TurnId, VibeSettings } from "@t3tools/con import { assert, it } from "@effect/vitest"; 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"; @@ -43,6 +44,21 @@ const vibeAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string) => makeVibeAdapter(decodeVibeSettings({ binaryPath })).pipe(Effect.orDie); +const waitForActiveTurn = ( + adapter: Effect.Effect.Success>, + threadId: ThreadId, + attempts = 80, +): Effect.Effect => + Effect.gen(function* () { + if (attempts <= 0) { + return yield* Effect.die(new Error("Timed out waiting for an active Vibe turn.")); + } + const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); + if (session?.activeTurnId !== undefined) return session.activeTurnId; + yield* Effect.sleep("25 millis"); + return yield* waitForActiveTurn(adapter, threadId, attempts - 1); + }); + function waitForFileContent(filePath: string, attempts = 80): Effect.Effect { const readAttempt = (remainingAttempts: number): Effect.Effect => Effect.gen(function* () { @@ -69,6 +85,33 @@ it("requires a prompt settlement to match the active Vibe turn", () => { }); it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { + it.effect("does not hang a prompt when its session is stopped", () => + Effect.gen(function* () { + const wrapperPath = yield* Effect.promise(() => + makeMockVibeWrapper({ T3_ACP_HANG_PROMPT_FOREVER: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("vibe-stop-active-prompt-thread"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("vibe"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const turnFiber = yield* adapter + .sendTurn({ threadId, input: "keep running", attachments: [] }) + .pipe(Effect.forkChild); + yield* waitForActiveTurn(adapter, threadId); + + yield* adapter.stopSession(threadId).pipe(Effect.timeout("2 seconds")); + const turnExit = yield* Fiber.await(turnFiber).pipe(Effect.timeout("2 seconds")); + + assert.isTrue(Exit.isFailure(turnExit)); + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); + it.effect("restarts an existing thread when session inputs change", () => Effect.gen(function* () { const wrapperPath = yield* Effect.promise(() => makeMockVibeWrapper()); diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 71cc60456db61..f2a654b7b72f9 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -74,6 +74,7 @@ interface VibeSessionContext { readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly pendingApprovals: Map; + readonly stoppedSignal: Deferred.Deferred; session: ProviderSession; turns: Array<{ id: TurnId; items: Array }>; notificationFiber?: Fiber.Fiber; @@ -162,12 +163,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt 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 }, ); - if (ctx.notificationFiber) yield* Fiber.interrupt(ctx.notificationFiber); yield* Scope.close(ctx.scope, Exit.void).pipe(Effect.ignore); sessions.delete(ctx.threadId); yield* publish({ @@ -211,6 +212,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }).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; @@ -368,6 +370,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt scope: sessionScope, acp, pendingApprovals, + stoppedSignal, session, turns: [], stopped: false, @@ -556,8 +559,10 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const result = yield* ctx.acp.prompt({ prompt }).pipe( Effect.tapError((cause) => Effect.gen(function* () { - yield* ctx.acp.drainEvents; - if (!vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) return; + yield* Effect.raceFirst(ctx.acp.drainEvents, Deferred.await(ctx.stoppedSignal)); + if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { + return; + } delete ctx.activeTurnId; const { activeTurnId: _activeTurnId, ...ready } = ctx.session; ctx.session = { @@ -582,9 +587,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), ), ); - yield* ctx.acp.drainEvents; + yield* Effect.raceFirst(ctx.acp.drainEvents, Deferred.await(ctx.stoppedSignal)); ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); - if (!vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { + if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; } const updatedAt = yield* nowIso; From e45435a08dd4366d58ebcce869d2e1d1d78e7f7c Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 12:19:25 +0200 Subject: [PATCH 06/20] [FIX] Serialize Vibe session startup per thread --- .../src/provider/Layers/VibeAdapter.test.ts | 58 ++++++++++++++++++- .../server/src/provider/Layers/VibeAdapter.ts | 37 +++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 91b5d9032b9e4..840f84cd50e63 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ServerConfig } from "../../config.ts"; +import type { VibeAdapterShape } from "../Services/VibeAdapter.ts"; import { makeVibeAdapter, vibePromptSettlementBelongsToTurn } from "./VibeAdapter.ts"; const decodeVibeSettings = Schema.decodeSync(VibeSettings); @@ -45,7 +46,7 @@ const makeTestAdapter = (binaryPath: string) => makeVibeAdapter(decodeVibeSettings({ binaryPath })).pipe(Effect.orDie); const waitForActiveTurn = ( - adapter: Effect.Effect.Success>, + adapter: VibeAdapterShape, threadId: ThreadId, attempts = 80, ): Effect.Effect => @@ -75,6 +76,29 @@ 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 ${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("requires a prompt settlement to match the active Vibe turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); @@ -145,6 +169,38 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }), ); + 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("stops active ACP sessions when the adapter scope closes", () => Effect.gen(function* () { const tempDir = yield* Effect.promise(() => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index f2a654b7b72f9..88420300b19fb 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -18,10 +18,13 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; 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 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"; @@ -89,6 +92,13 @@ export function vibePromptSettlementBelongsToTurn( return activeTurnId === turnId; } +export function drainVibeEventsUnlessStopped( + drainEvents: Effect.Effect, + stoppedSignal: Deferred.Deferred, +): Effect.Effect { + return Effect.raceFirst(drainEvents, Deferred.await(stoppedSignal)); +} + function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, @@ -121,6 +131,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); const events = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -148,6 +159,23 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ? 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( @@ -180,7 +208,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }); }); - const startSession: VibeAdapterShape["startSession"] = (input) => + const startSessionUnlocked: VibeAdapterShape["startSession"] = (input) => Effect.gen(function* () { if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ @@ -474,6 +502,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt return session; }).pipe(Effect.scoped); + const startSession: VibeAdapterShape["startSession"] = (input) => + withThreadLock(input.threadId, startSessionUnlocked(input)); + const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); @@ -559,7 +590,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const result = yield* ctx.acp.prompt({ prompt }).pipe( Effect.tapError((cause) => Effect.gen(function* () { - yield* Effect.raceFirst(ctx.acp.drainEvents, Deferred.await(ctx.stoppedSignal)); + yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { return; } @@ -587,7 +618,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), ), ); - yield* Effect.raceFirst(ctx.acp.drainEvents, Deferred.await(ctx.stoppedSignal)); + yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; From 111a9e2c4fcf0dfae3465a9c773e755b3da8ce79 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 12:29:04 +0200 Subject: [PATCH 07/20] [FIX] Always settle Vibe turns after ACP drain failures --- .../src/provider/Layers/VibeAdapter.test.ts | 67 +++++++------------ .../server/src/provider/Layers/VibeAdapter.ts | 4 +- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 840f84cd50e63..168759ec2684a 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -7,16 +7,19 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, 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 { ServerConfig } from "../../config.ts"; -import type { VibeAdapterShape } from "../Services/VibeAdapter.ts"; -import { makeVibeAdapter, vibePromptSettlementBelongsToTurn } from "./VibeAdapter.ts"; +import { + drainVibeEventsUnlessStopped, + makeVibeAdapter, + vibePromptSettlementBelongsToTurn, +} from "./VibeAdapter.ts"; const decodeVibeSettings = Schema.decodeSync(VibeSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -45,21 +48,6 @@ const vibeAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string) => makeVibeAdapter(decodeVibeSettings({ binaryPath })).pipe(Effect.orDie); -const waitForActiveTurn = ( - adapter: VibeAdapterShape, - threadId: ThreadId, - attempts = 80, -): Effect.Effect => - Effect.gen(function* () { - if (attempts <= 0) { - return yield* Effect.die(new Error("Timed out waiting for an active Vibe turn.")); - } - const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); - if (session?.activeTurnId !== undefined) return session.activeTurnId; - yield* Effect.sleep("25 millis"); - return yield* waitForActiveTurn(adapter, threadId, attempts - 1); - }); - function waitForFileContent(filePath: string, attempts = 80): Effect.Effect { const readAttempt = (remainingAttempts: number): Effect.Effect => Effect.gen(function* () { @@ -108,34 +96,29 @@ it("requires a prompt settlement to match the active Vibe turn", () => { assert.isTrue(vibePromptSettlementBelongsToTurn(staleTurnId, staleTurnId)); }); -it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { - it.effect("does not hang a prompt when its session is stopped", () => - Effect.gen(function* () { - const wrapperPath = yield* Effect.promise(() => - makeMockVibeWrapper({ T3_ACP_HANG_PROMPT_FOREVER: "1" }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const threadId = ThreadId.make("vibe-stop-active-prompt-thread"); +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* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("vibe"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const turnFiber = yield* adapter - .sendTurn({ threadId, input: "keep running", attachments: [] }) - .pipe(Effect.forkChild); - yield* waitForActiveTurn(adapter, threadId); + yield* drainVibeEventsUnlessStopped(Effect.never, stoppedSignal).pipe( + Effect.timeout("250 millis"), + ); + }), +); - yield* adapter.stopSession(threadId).pipe(Effect.timeout("2 seconds")); - const turnExit = yield* Fiber.await(turnFiber).pipe(Effect.timeout("2 seconds")); +it.effect("ignores ACP event drainage failures so turn settlement can continue", () => + Effect.gen(function* () { + const stoppedSignal = yield* Deferred.make(); - assert.isTrue(Exit.isFailure(turnExit)); - assert.isFalse(yield* adapter.hasSession(threadId)); - }), - ); + 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()); diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 88420300b19fb..f064e1b05b2ae 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -96,7 +96,9 @@ export function drainVibeEventsUnlessStopped( drainEvents: Effect.Effect, stoppedSignal: Deferred.Deferred, ): Effect.Effect { - return Effect.raceFirst(drainEvents, Deferred.await(stoppedSignal)); + return Effect.raceFirst(drainEvents, Deferred.await(stoppedSignal)).pipe( + Effect.catchCause(() => Effect.void), + ); } function selectPermissionOptionId( From 9c298bfaaf0bf4730af19f1520d82672275f7677 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 12:39:50 +0200 Subject: [PATCH 08/20] [FIX] Harden Vibe text generation and turn cancellation --- apps/server/scripts/acp-mock-agent.ts | 21 ++++- .../src/provider/Layers/VibeAdapter.test.ts | 77 ++++++++++++++++++- .../server/src/provider/Layers/VibeAdapter.ts | 13 ---- .../src/provider/Layers/VibeProvider.test.ts | 46 +++++++++++ .../src/provider/Layers/VibeProvider.ts | 2 +- .../src/provider/acp/AcpSessionRuntime.ts | 23 ++++-- .../server/src/provider/acp/VibeAcpSupport.ts | 1 + .../textGeneration/VibeTextGeneration.test.ts | 65 ++++++++++++++++ .../src/textGeneration/VibeTextGeneration.ts | 2 +- 9 files changed, 227 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/textGeneration/VibeTextGeneration.test.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 0fe6ff3873f73..b48bb2c764453 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -57,6 +57,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, @@ -505,6 +521,7 @@ const program = Effect.gen(function* () { }); }); } + pendingCancellationResolvers.get(cancelledSessionId)?.(); }), ); @@ -575,7 +592,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) { diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 168759ec2684a..cb2bc1be4e416 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -5,14 +5,22 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId, TurnId, VibeSettings } from "@t3tools/contracts"; +import { + ProviderDriverKind, + 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 { ServerConfig } from "../../config.ts"; import { @@ -184,6 +192,73 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }), ); + it.effect("keeps a cancelled prompt as the active turn until Vibe settles it", () => + Effect.gen(function* () { + 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", + }), + ); + 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* 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.equal(lateDelta?.turnId, firstTurnId); + assert.notEqual(lateDelta?.turnId, followUp.turnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("6 seconds")), + ); + it.effect("stops active ACP sessions when the adapter scope closes", () => Effect.gen(function* () { const tempDir = yield* Effect.promise(() => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index f064e1b05b2ae..af2cf2574d415 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -654,19 +654,6 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true }, ); - if (activeTurnId) { - delete ctx.activeTurnId; - const { activeTurnId: _activeTurnId, ...ready } = ctx.session; - ctx.session = { ...ready, status: "ready", updatedAt: yield* nowIso }; - yield* publish({ - type: "turn.completed", - ...(yield* stamp()), - provider: PROVIDER, - threadId, - turnId: activeTurnId, - payload: { state: "cancelled", stopReason: "cancelled" }, - }); - } }); const respondToRequest: VibeAdapterShape["respondToRequest"] = ( diff --git a/apps/server/src/provider/Layers/VibeProvider.test.ts b/apps/server/src/provider/Layers/VibeProvider.test.ts index ee72f4349fe10..163c4a72eb314 100644 --- a/apps/server/src/provider/Layers/VibeProvider.test.ts +++ b/apps/server/src/provider/Layers/VibeProvider.test.ts @@ -1,3 +1,7 @@ +// @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"; @@ -14,6 +18,12 @@ import { } 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 = [ { @@ -78,6 +88,42 @@ describe("VibeProvider", () => { }); 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( diff --git a/apps/server/src/provider/Layers/VibeProvider.ts b/apps/server/src/provider/Layers/VibeProvider.ts index e88c481da760d..1c926d36aed1d 100644 --- a/apps/server/src/provider/Layers/VibeProvider.ts +++ b/apps/server/src/provider/Layers/VibeProvider.ts @@ -308,7 +308,7 @@ export const checkVibeProviderStatus = Effect.fn("checkVibeProviderStatus")(func installed: true, version, status: "ready", - auth: { status: "authenticated", type: "Mistral account" }, + auth: { status: "unknown" }, }, }); }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index caafbf9b7ed67..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"]; @@ -769,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.ts b/apps/server/src/provider/acp/VibeAcpSupport.ts index 505b554fe18bd..72e1c51581605 100644 --- a/apps/server/src/provider/acp/VibeAcpSupport.ts +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -101,6 +101,7 @@ export const makeVibeAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, + interruptPromptOnCancel: false, spawn: buildVibeAcpSpawnInput(input.vibeSettings, input.cwd, input.environment), }).pipe( Layer.provide( diff --git a/apps/server/src/textGeneration/VibeTextGeneration.test.ts b/apps/server/src/textGeneration/VibeTextGeneration.test.ts new file mode 100644 index 0000000000000..d49a7ad185a27 --- /dev/null +++ b/apps/server/src/textGeneration/VibeTextGeneration.test.ts @@ -0,0 +1,65 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +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 { ProviderInstanceId, VibeSettings } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { expect } from "vite-plus/test"; + +import { makeVibeTextGeneration } from "./VibeTextGeneration.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("'", `'"'"'`)}'`; +} + +function makeVibeWrapper(dir: string, env: Record): 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 index cf6d1afe23d8a..b77a2ab0fe4c8 100644 --- a/apps/server/src/textGeneration/VibeTextGeneration.ts +++ b/apps/server/src/textGeneration/VibeTextGeneration.ts @@ -71,7 +71,7 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi runtime, model: input.modelSelection.model, selections: input.modelSelection.options, - mode: "chat", + mode: "default", mapError: (cause, method) => new TextGenerationError({ operation: input.operation, From 5b9f40ef8e8a6cb898d7b120d770f2d4601eb661 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 13:19:30 +0200 Subject: [PATCH 09/20] [FIX] Reserve the Vibe turn slot under the per-thread lock --- apps/server/scripts/acp-mock-agent.ts | 4 + .../src/provider/Layers/VibeAdapter.test.ts | 70 ++++++++ .../server/src/provider/Layers/VibeAdapter.ts | 167 ++++++++++-------- 3 files changed, 165 insertions(+), 76 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index b48bb2c764453..3e92968866dd3 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -41,6 +41,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", @@ -475,6 +476,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", diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index cb2bc1be4e416..b1f611ba42c25 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -7,6 +7,7 @@ import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, + ProviderInstanceId, type ProviderRuntimeEvent, ThreadId, TurnId, @@ -21,6 +22,7 @@ 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 { @@ -259,6 +261,74 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }).pipe(Effect.scoped, Effect.timeout("6 seconds")), ); + it.effect("rejects a concurrent 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_SET_CONFIG_OPTION_DELAY_MS: "300", + }), + ); + 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.exit, Effect.forkChild); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/set_config_option"', 2); + const second = yield* adapter + .sendTurn({ threadId, input: "second concurrent turn", attachments: [] }) + .pipe(Effect.exit); + const first = yield* Fiber.join(firstFiber); + + assert.isTrue(Exit.isSuccess(first)); + assert.isTrue(Exit.isFailure(second)); + + 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); + + 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(() => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index af2cf2574d415..0f7b8eb646244 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -507,87 +507,102 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const startSession: VibeAdapterShape["startSession"] = (input) => withThreadLock(input.threadId, startSessionUnlocked(input)); - const sendTurn: VibeAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - if (ctx.activeTurnId) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Mistral Vibe already has an active turn.", - }); - } - 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, + /** + * Claims the thread's single turn slot under the per-thread lock. Both the + * active-turn check and the configuration RPCs happen inside the lock, so + * concurrent sendTurn calls can neither both pass the check nor interleave + * their session configuration. The lock is released before the prompt runs + * so a long turn never blocks session lifecycle operations. + */ + const prepareTurn = (input: Parameters[0]) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Mistral Vibe already has an active turn.", }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, + } + 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, }); - } - 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({ + 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.", + }); + } + const turnId = TurnId.make(yield* nextId); + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + yield* publish({ + type: "turn.started", + ...(yield* stamp()), provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, }); - } + return { ctx, turnId, prompt }; + }), + ); - const turnId = TurnId.make(yield* nextId); - ctx.activeTurnId = turnId; - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), - }; - yield* publish({ - type: "turn.started", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, - }); + const sendTurn: VibeAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const { ctx, turnId, prompt } = yield* prepareTurn(input); const result = yield* ctx.acp.prompt({ prompt }).pipe( Effect.tapError((cause) => From 5363235eeb435de1f1e71a4ef9bfbde814058eaa Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 14:20:04 +0200 Subject: [PATCH 10/20] fix(vibe): harden lifecycle cancellation and trust handling --- apps/server/scripts/acp-mock-agent.ts | 6 +- .../src/provider/Layers/VibeAdapter.test.ts | 148 ++++++- .../server/src/provider/Layers/VibeAdapter.ts | 367 ++++++++++++------ 3 files changed, 390 insertions(+), 131 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 3e92968866dd3..59b919adc68d9 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -22,6 +22,7 @@ const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === 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"; 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"; @@ -957,7 +958,10 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { - if (method === "_trust/status" || method === "_trust/decision") { + if (method === "_trust/status") { + return Effect.succeed({ trust_status: vibeTrustStatus, details: null }); + } + if (method === "_trust/decision") { return Effect.succeed({ trust_status: "trusted", details: null }); } diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index b1f611ba42c25..6ce24032567f7 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -55,8 +55,8 @@ const vibeAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3code-vibe-adapter-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); -const makeTestAdapter = (binaryPath: string) => - makeVibeAdapter(decodeVibeSettings({ binaryPath })).pipe(Effect.orDie); +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 => @@ -194,13 +194,154 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }), ); + 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("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("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 result = yield* Fiber.join(sendFiber); + yield* Effect.yieldNow; + + const requests = yield* waitForFileContent(requestLogPath); + 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.lengthOf(started, 1); + assert.deepEqual( + completed.map((event) => [event.turnId, event.payload.state]), + [[result.turnId, "cancelled"]], + ); + assert.notInclude(requests, '"method":"session/prompt"'); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped, Effect.timeout("5 seconds"), TestClock.withLive), + ); + it.effect("keeps a cancelled prompt as the active turn until Vibe settles it", () => 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); @@ -230,6 +371,7 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { .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")); @@ -258,7 +400,7 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { yield* Fiber.interrupt(eventsFiber); yield* adapter.stopSession(threadId); - }).pipe(Effect.scoped, Effect.timeout("6 seconds")), + }).pipe(Effect.scoped, Effect.timeout("6 seconds"), TestClock.withLive), ); it.effect("rejects a concurrent turn instead of opening a second Vibe turn", () => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 0f7b8eb646244..12fee521b640f 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -80,8 +80,12 @@ interface VibeSessionContext { readonly stoppedSignal: Deferred.Deferred; session: ProviderSession; turns: Array<{ id: TurnId; items: Array }>; + readonly interruptedTurnIds: Set; notificationFiber?: Fiber.Fiber; activeTurnId?: TurnId; + promptStartedSignal?: Deferred.Deferred; + turnPreparationInProgress: boolean; + interruptPreparationRequested: boolean; stopped: boolean; } @@ -247,6 +251,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const modelSelection = input.modelSelection?.instanceId === instanceId ? input.modelSelection : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + let liveContext: VibeSessionContext | undefined; const acp = yield* makeVibeAcpRuntime({ vibeSettings: settings, ...(options?.environment ? { environment: options.environment } : {}), @@ -254,6 +259,14 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt cwd, ...(resumedSessionId ? { resumeSessionId: resumedSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, + requestLogger: (event) => { + const promptStartedSignal = liveContext?.promptStartedSignal; + return event.method === "session/prompt" && + event.status === "started" && + promptStartedSignal + ? Deferred.succeed(promptStartedSignal, undefined).pipe(Effect.asVoid) + : Effect.void; + }, ...(mcpSession ? { mcpServers: [ @@ -346,15 +359,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt mapAcpToAdapterError(PROVIDER, input.threadId, "trust/status", cause), ), ); - if (trust.trust_status === "untrusted") { - if (!settings.trustWorkspace) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: - "Vibe has not trusted this workspace, so repository instructions are ignored. Enable “Trust workspaces” for this Vibe instance or trust it from the Vibe CLI.", - }); - } + if (trust.trust_status === "untrusted" && settings.trustWorkspace) { const trusted = yield* trustVibeWorkspace(acp, { cwd, sessionId: started.sessionId, @@ -403,9 +408,13 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt stoppedSignal, session, turns: [], + interruptedTurnIds: new Set(), + turnPreparationInProgress: false, + interruptPreparationRequested: false, stopped: false, }; sessions.set(input.threadId, ctx); + liveContext = ctx; ctx.notificationFiber = yield* Stream.runForEach(acp.getEvents(), (event) => Effect.gen(function* () { @@ -507,6 +516,39 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const startSession: VibeAdapterShape["startSession"] = (input) => withThreadLock(input.threadId, startSessionUnlocked(input)); + const settleActiveTurn = ( + ctx: VibeSessionContext, + turnId: TurnId, + payload: Extract["payload"], + ) => + Effect.gen(function* () { + if ( + ctx.stopped || + sessions.get(ctx.threadId) !== ctx || + !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) + ) { + return false; + } + delete ctx.activeTurnId; + delete ctx.promptStartedSignal; + ctx.interruptedTurnIds.delete(turnId); + const { activeTurnId: _activeTurnId, ...ready } = ctx.session; + ctx.session = { + ...ready, + status: "ready", + updatedAt: yield* nowIso, + }; + yield* publish({ + type: "turn.completed", + ...(yield* stamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + }); + return true; + }); + /** * Claims the thread's single turn slot under the per-thread lock. Both the * active-turn check and the configuration RPCs happen inside the lock, so @@ -526,109 +568,140 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt issue: "Mistral Vibe already has an active turn.", }); } - 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}'.`, + ctx.turnPreparationInProgress = true; + ctx.interruptPreparationRequested = false; + 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, }); - } - 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({ + 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.", + }); + } + const turnId = TurnId.make(yield* nextId); + const promptStartedSignal = yield* Deferred.make(); + ctx.activeTurnId = turnId; + ctx.promptStartedSignal = promptStartedSignal; + if (ctx.interruptPreparationRequested) { + ctx.interruptedTurnIds.add(turnId); + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + yield* publish({ + type: "turn.started", + ...(yield* stamp()), provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, }); - } - const turnId = TurnId.make(yield* nextId); - ctx.activeTurnId = turnId; - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), - }; - yield* publish({ - type: "turn.started", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, - }); - return { ctx, turnId, prompt }; + return { ctx, turnId, prompt, promptStartedSignal }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.turnPreparationInProgress = false; + ctx.interruptPreparationRequested = false; + }), + ), + ); }), ); const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const { ctx, turnId, prompt } = yield* prepareTurn(input); + const { ctx, turnId, prompt, promptStartedSignal } = yield* prepareTurn(input); + const promptStart = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const live = sessions.get(input.threadId); + if ( + live !== ctx || + ctx.stopped || + !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) + ) { + return { _tag: "Skipped" as const }; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settleActiveTurn(ctx, turnId, { + state: "cancelled", + stopReason: "cancelled", + }); + return { _tag: "Skipped" as const }; + } + const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); + yield* Effect.raceFirst( + Deferred.await(promptStartedSignal), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + return { _tag: "Started" as const, fiber }; + }), + ); + if (promptStart._tag === "Skipped") { + return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; + } - const result = yield* ctx.acp.prompt({ prompt }).pipe( + const result = yield* Fiber.join(promptStart.fiber).pipe( Effect.tapError((cause) => Effect.gen(function* () { yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); - if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { - return; - } - delete ctx.activeTurnId; - const { activeTurnId: _activeTurnId, ...ready } = ctx.session; - ctx.session = { - ...ready, - status: "ready", - updatedAt: yield* nowIso, - }; - yield* publish({ - type: "turn.completed", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { + yield* withThreadLock( + input.threadId, + settleActiveTurn(ctx, turnId, { state: "failed", errorMessage: cause.message || "Mistral Vibe prompt request failed.", - }, - }); + }), + ); }).pipe(Effect.catchCause(() => Effect.void)), ), Effect.mapError((cause) => @@ -636,38 +709,65 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ), ); yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); - ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); - if (ctx.stopped || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId)) { - return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; - } - const updatedAt = yield* nowIso; - delete ctx.activeTurnId; - const { activeTurnId: _activeTurnId, ...ready } = ctx.session; - ctx.session = { ...ready, status: "ready", updatedAt }; - yield* publish({ - type: "turn.completed", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason, - }, - }); + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + if ( + ctx.stopped || + sessions.get(input.threadId) !== ctx || + !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) + ) { + return; + } + ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + const wasInterrupted = ctx.interruptedTurnIds.has(turnId); + yield* settleActiveTurn(ctx, turnId, { + state: + wasInterrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: wasInterrupted ? "cancelled" : result.stopReason, + }); + }), + ); return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; }); const interruptTurn: VibeAdapterShape["interruptTurn"] = (threadId, turnId) => Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const activeTurnId = ctx.activeTurnId; - if (turnId && activeTurnId && turnId !== activeTurnId) return; - yield* ctx.acp.cancel.pipe(Effect.ignore); - yield* Effect.forEach( - ctx.pendingApprovals.values(), - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { discard: true }, + yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) return; + const activeTurnId = ctx.activeTurnId; + if (turnId && activeTurnId && turnId !== activeTurnId) return; + if (activeTurnId) { + ctx.interruptedTurnIds.add(activeTurnId); + } else if (ctx.turnPreparationInProgress && turnId === undefined) { + ctx.interruptPreparationRequested = true; + } + }); + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const activeTurnId = ctx.activeTurnId; + if (turnId && activeTurnId && turnId !== activeTurnId) return; + yield* Effect.forEach( + ctx.pendingApprovals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + if (!activeTurnId) return; + const promptStarted = + ctx.promptStartedSignal !== undefined && + (yield* Deferred.isDone(ctx.promptStartedSignal)); + if (!promptStarted) { + yield* settleActiveTurn(ctx, activeTurnId, { + state: "cancelled", + stopReason: "cancelled", + }); + return; + } + yield* ctx.acp.cancel.pipe(Effect.ignore); + }), ); }); @@ -719,13 +819,26 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }); const stopSession: VibeAdapterShape["stopSession"] = (threadId) => - Effect.flatMap(requireSession(threadId), stopContext); + withThreadLock( + threadId, + Effect.suspend(() => Effect.flatMap(requireSession(threadId), stopContext)), + ); const listSessions: VibeAdapterShape["listSessions"] = () => Effect.succeed(Array.from(sessions.values(), (ctx) => ctx.session)); const hasSession: VibeAdapterShape["hasSession"] = (threadId) => Effect.succeed(sessions.has(threadId)); const stopAll: VibeAdapterShape["stopAll"] = () => - Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }); + 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(() => Effect.forEach(Array.from(sessions.values()), stopContext, { discard: true }).pipe( From 5717f95702f4e7a120381b1f7d6e31127e42ad22 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 15:43:05 +0200 Subject: [PATCH 11/20] [FIX] Cancel holds thread lock - release lock before ACP cancel Fixes cursor bot issue: interruptTurn was holding the per-thread lock while awaiting the ACP cancel operation. If the agent's cancel response is slow or never arrives, this lock remains held indefinitely, blocking all subsequent operations like sendTurn or stopSession for that thread. The fix moves the ACP cancel call outside the thread lock: - The lock protects session validation, pending approval cancellation, and local turn settlement - The actual ACP cancel effect executes after the lock is released Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- apps/server/src/provider/Layers/VibeAdapter.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 12fee521b640f..90854dc4eb839 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -744,18 +744,18 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ctx.interruptPreparationRequested = true; } }); - yield* withThreadLock( + const shouldCancelAcp = yield* withThreadLock( threadId, Effect.gen(function* () { const ctx = yield* requireSession(threadId); const activeTurnId = ctx.activeTurnId; - if (turnId && activeTurnId && turnId !== activeTurnId) return; + if (turnId && activeTurnId && turnId !== activeTurnId) return false; yield* Effect.forEach( ctx.pendingApprovals.values(), (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true }, ); - if (!activeTurnId) return; + if (!activeTurnId) return false; const promptStarted = ctx.promptStartedSignal !== undefined && (yield* Deferred.isDone(ctx.promptStartedSignal)); @@ -764,11 +764,17 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt state: "cancelled", stopReason: "cancelled", }); - return; + return false; } - yield* ctx.acp.cancel.pipe(Effect.ignore); + return true; }), ); + if (shouldCancelAcp) { + const ctx = sessions.get(threadId); + if (ctx && !ctx.stopped) { + yield* ctx.acp.cancel.pipe(Effect.ignore); + } + } }); const respondToRequest: VibeAdapterShape["respondToRequest"] = ( From 374d7a0c7be53554be4c4791a01d0ce3f56c7bad Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 16:11:30 +0200 Subject: [PATCH 12/20] [FIX] Wrong-session cancel race - capture acp reference under lock Fixes cursor bot issue: Moving acp.cancel outside the thread lock created a race condition where a concurrent startSession or new turn could replace the session context for the threadId, causing acp.cancel to target the wrong session/turn. The fix captures the acp.cancel effect reference while still holding the lock, then executes it after the lock is released: - Inside lock: validate session, capture ctx.acp.cancel as Effect - Outside lock: execute the captured cancel effect This ensures: - No indefinite lock holding (cancel executes outside lock) - No wrong-session race (acp reference captured under lock) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../server/src/provider/Layers/VibeAdapter.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 90854dc4eb839..482aaec7745e8 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -744,18 +744,19 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ctx.interruptPreparationRequested = true; } }); - const shouldCancelAcp = yield* withThreadLock( + const acpCancel = yield* withThreadLock( threadId, Effect.gen(function* () { const ctx = yield* requireSession(threadId); const activeTurnId = ctx.activeTurnId; - if (turnId && activeTurnId && turnId !== activeTurnId) return false; + if (turnId && activeTurnId && turnId !== activeTurnId) + return Option.none>(); yield* Effect.forEach( ctx.pendingApprovals.values(), (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true }, ); - if (!activeTurnId) return false; + if (!activeTurnId) return Option.none>(); const promptStarted = ctx.promptStartedSignal !== undefined && (yield* Deferred.isDone(ctx.promptStartedSignal)); @@ -764,17 +765,15 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt state: "cancelled", stopReason: "cancelled", }); - return false; + return Option.none>(); } - return true; + return Option.some(ctx.acp.cancel.pipe(Effect.ignore)); }), ); - if (shouldCancelAcp) { - const ctx = sessions.get(threadId); - if (ctx && !ctx.stopped) { - yield* ctx.acp.cancel.pipe(Effect.ignore); - } - } + yield* Option.match(acpCancel, { + onNone: () => Effect.void, + onSome: (cancelEffect) => cancelEffect, + }); }); const respondToRequest: VibeAdapterShape["respondToRequest"] = ( From 68c43f13a6691b30cf670df4f0582d92812dbd43 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 16:20:02 +0200 Subject: [PATCH 13/20] [FIX] Rapid follow-up messages fail due to double lock acquisition Problem: When messages are sent rapidly, the second sendTurn would fail with 'Mistral Vibe already has an active turn' error. This was caused by prepareTurn and sendTurn each acquiring the thread lock separately, creating a window where the second prepareTurn could see activeTurnId set from the first turn. Solution: Merged prepareTurn and prompt start into a single function (prepareAndStartTurn) that holds the lock continuously. The prompt is still forked so it doesn't block the lock, but activeTurnId is set before the prompt starts, ensuring proper serialization of turns. This allows rapid follow-up messages to queue properly instead of erroring. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../server/src/provider/Layers/VibeAdapter.ts | 214 +++++++++--------- 1 file changed, 101 insertions(+), 113 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 482aaec7745e8..1003b288cc411 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -553,10 +553,11 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt * Claims the thread's single turn slot under the per-thread lock. Both the * active-turn check and the configuration RPCs happen inside the lock, so * concurrent sendTurn calls can neither both pass the check nor interleave - * their session configuration. The lock is released before the prompt runs - * so a long turn never blocks session lifecycle operations. + * their session configuration. The prompt is also started under the lock + * to ensure the activeTurnId is not prematurely set, preventing race conditions + * with rapid successive messages. */ - const prepareTurn = (input: Parameters[0]) => + const prepareAndStartTurn = (input: Parameters[0]) => withThreadLock( input.threadId, Effect.gen(function* () { @@ -570,128 +571,115 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt } ctx.turnPreparationInProgress = true; ctx.interruptPreparationRequested = false; - 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.", + 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, }); - } - const turnId = TurnId.make(yield* nextId); - const promptStartedSignal = yield* Deferred.make(); - ctx.activeTurnId = turnId; - ctx.promptStartedSignal = promptStartedSignal; - if (ctx.interruptPreparationRequested) { - ctx.interruptedTurnIds.add(turnId); - } - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), - }; - yield* publish({ - type: "turn.started", - ...(yield* stamp()), + 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, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", }); - return { ctx, turnId, prompt, promptStartedSignal }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { + } + const turnId = TurnId.make(yield* nextId); + const promptStartedSignal = yield* Deferred.make(); + ctx.activeTurnId = turnId; + ctx.promptStartedSignal = promptStartedSignal; + if (ctx.interruptPreparationRequested) { + ctx.interruptedTurnIds.add(turnId); + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + yield* publish({ + type: "turn.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); + yield* Effect.raceFirst( + Deferred.await(promptStartedSignal), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + return { ctx, turnId, prompt, promptStartedSignal, fiber, modelSelection }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + const ctx = sessions.get(input.threadId); + if (ctx) { ctx.turnPreparationInProgress = false; ctx.interruptPreparationRequested = false; - }), - ), - ); - }), + } + }), + ), + ), ); const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const { ctx, turnId, prompt, promptStartedSignal } = yield* prepareTurn(input); - const promptStart = yield* withThreadLock( - input.threadId, - Effect.gen(function* () { - const live = sessions.get(input.threadId); - if ( - live !== ctx || - ctx.stopped || - !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) - ) { - return { _tag: "Skipped" as const }; - } - if (ctx.interruptedTurnIds.has(turnId)) { - yield* settleActiveTurn(ctx, turnId, { - state: "cancelled", - stopReason: "cancelled", - }); - return { _tag: "Skipped" as const }; - } - const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); - yield* Effect.raceFirst( - Deferred.await(promptStartedSignal), - Fiber.await(fiber).pipe(Effect.asVoid), - ); - return { _tag: "Started" as const, fiber }; - }), - ); - if (promptStart._tag === "Skipped") { + const { ctx, turnId, prompt, fiber, modelSelection } = yield* prepareAndStartTurn(input); + if (ctx.interruptedTurnIds.has(turnId)) { + yield* withThreadLock( + input.threadId, + settleActiveTurn(ctx, turnId, { + state: "cancelled", + stopReason: "cancelled", + }), + ); return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; } - const result = yield* Fiber.join(promptStart.fiber).pipe( + const result = yield* Fiber.join(fiber).pipe( Effect.tapError((cause) => Effect.gen(function* () { yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); From 0921d8663b458187577edbbcaf94480aef342a00 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 16:28:27 +0200 Subject: [PATCH 14/20] Fix Vibe turn steering for rapid follow-up sendTurn calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align Vibe’s provider adapter behavior with Cursor/Grok by reusing the active turn id when multiple prompts overlap, and only settling the merged turn when the last prompt finishes. --- .../src/provider/Layers/VibeAdapter.test.ts | 19 ++- .../server/src/provider/Layers/VibeAdapter.ts | 129 +++++++++++++----- 2 files changed, 106 insertions(+), 42 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 6ce24032567f7..c72b5829043ed 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -403,7 +403,7 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }).pipe(Effect.scoped, Effect.timeout("6 seconds"), TestClock.withLive), ); - it.effect("rejects a concurrent turn instead of opening a second Vibe turn", () => + 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-")), @@ -412,7 +412,7 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { const wrapperPath = yield* Effect.promise(() => makeMockVibeWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "300", + T3_ACP_PROMPT_DELAY_MS: "1200", }), ); const adapter = yield* makeTestAdapter(wrapperPath); @@ -442,15 +442,14 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { options: [{ id: "thinking", value: "low" }], }, }) - .pipe(Effect.exit, Effect.forkChild); - yield* waitForFileOccurrences(requestLogPath, '"method":"session/set_config_option"', 2); + .pipe(Effect.forkChild); + yield* waitForFileOccurrences(requestLogPath, '"method":"session/prompt"', 1); const second = yield* adapter .sendTurn({ threadId, input: "second concurrent turn", attachments: [] }) - .pipe(Effect.exit); + .pipe(Effect.timeout("4 seconds")); const first = yield* Fiber.join(firstFiber); - assert.isTrue(Exit.isSuccess(first)); - assert.isTrue(Exit.isFailure(second)); + assert.equal(String(first.turnId), String(second.turnId)); const started = events.filter( (event) => event.type === "turn.started" && event.threadId === threadId, @@ -465,6 +464,12 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { ["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); yield* Fiber.interrupt(eventsFiber); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 1003b288cc411..4e2f733f273af 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -84,6 +84,8 @@ interface VibeSessionContext { notificationFiber?: Fiber.Fiber; activeTurnId?: TurnId; promptStartedSignal?: Deferred.Deferred; + promptsInFlight: number; + startedPromptsInFlight: number; turnPreparationInProgress: boolean; interruptPreparationRequested: boolean; stopped: boolean; @@ -409,6 +411,8 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt session, turns: [], interruptedTurnIds: new Set(), + promptsInFlight: 0, + startedPromptsInFlight: 0, turnPreparationInProgress: false, interruptPreparationRequested: false, stopped: false, @@ -549,20 +553,52 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt return true; }); + const settlePromptInFlight = ( + ctx: VibeSessionContext, + turnId: TurnId, + options?: { + readonly promptStarted?: boolean; + readonly payload?: Extract["payload"]; + }, + ) => + Effect.gen(function* () { + if (ctx.stopped || sessions.get(ctx.threadId) !== ctx) { + return false; + } + if (options?.promptStarted) { + ctx.startedPromptsInFlight = Math.max(0, ctx.startedPromptsInFlight - 1); + } + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + if ( + remainingPrompts > 0 || + !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) || + !options?.payload + ) { + return false; + } + return yield* settleActiveTurn(ctx, turnId, options.payload); + }); + /** - * Claims the thread's single turn slot under the per-thread lock. Both the - * active-turn check and the configuration RPCs happen inside the lock, so - * concurrent sendTurn calls can neither both pass the check nor interleave - * their session configuration. The prompt is also started under the lock - * to ensure the activeTurnId is not prematurely set, preventing race conditions - * with rapid successive messages. + * 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 started while still holding + * the lock to reduce race conditions with rapid successive messages. */ const prepareAndStartTurn = (input: Parameters[0]) => withThreadLock( input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - if (ctx.activeTurnId) { + const activeTurnId = ctx.activeTurnId; + const steeringTurnId = + activeTurnId && !ctx.interruptedTurnIds.has(activeTurnId) ? activeTurnId : undefined; + if (activeTurnId && steeringTurnId === undefined) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", @@ -624,8 +660,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt issue: "Turn requires non-empty text or attachments.", }); } - const turnId = TurnId.make(yield* nextId); + const turnId = steeringTurnId ?? TurnId.make(yield* nextId); const promptStartedSignal = yield* Deferred.make(); + ctx.promptsInFlight += 1; ctx.activeTurnId = turnId; ctx.promptStartedSignal = promptStartedSignal; if (ctx.interruptPreparationRequested) { @@ -638,20 +675,26 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt updatedAt: yield* nowIso, model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), }; - yield* publish({ - type: "turn.started", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, - }); + if (steeringTurnId === undefined) { + yield* publish({ + type: "turn.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + } const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); yield* Effect.raceFirst( Deferred.await(promptStartedSignal), Fiber.await(fiber).pipe(Effect.asVoid), ); - return { ctx, turnId, prompt, promptStartedSignal, fiber, modelSelection }; + const promptStarted = yield* Deferred.isDone(promptStartedSignal); + if (promptStarted) { + ctx.startedPromptsInFlight += 1; + } + return { ctx, turnId, prompt, fiber, promptStarted }; }).pipe( Effect.ensuring( Effect.sync(() => { @@ -667,13 +710,13 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const { ctx, turnId, prompt, fiber, modelSelection } = yield* prepareAndStartTurn(input); + const { ctx, turnId, prompt, fiber, promptStarted } = yield* prepareAndStartTurn(input); if (ctx.interruptedTurnIds.has(turnId)) { yield* withThreadLock( input.threadId, - settleActiveTurn(ctx, turnId, { - state: "cancelled", - stopReason: "cancelled", + settlePromptInFlight(ctx, turnId, { + promptStarted, + payload: { state: "cancelled", stopReason: "cancelled" }, }), ); return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; @@ -685,9 +728,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); yield* withThreadLock( input.threadId, - settleActiveTurn(ctx, turnId, { - state: "failed", - errorMessage: cause.message || "Mistral Vibe prompt request failed.", + settlePromptInFlight(ctx, turnId, { + promptStarted, + payload: { + state: "failed", + errorMessage: cause.message || "Mistral Vibe prompt request failed.", + }, }), ); }).pipe(Effect.catchCause(() => Effect.void)), @@ -705,14 +751,23 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt sessions.get(input.threadId) !== ctx || !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) ) { + yield* settlePromptInFlight(ctx, turnId, { promptStarted }); return; } - ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); + } const wasInterrupted = ctx.interruptedTurnIds.has(turnId); - yield* settleActiveTurn(ctx, turnId, { - state: - wasInterrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: wasInterrupted ? "cancelled" : result.stopReason, + yield* settlePromptInFlight(ctx, turnId, { + promptStarted, + payload: { + state: + wasInterrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: wasInterrupted ? "cancelled" : result.stopReason, + }, }); }), ); @@ -746,12 +801,16 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ); if (!activeTurnId) return Option.none>(); const promptStarted = - ctx.promptStartedSignal !== undefined && - (yield* Deferred.isDone(ctx.promptStartedSignal)); - if (!promptStarted) { - yield* settleActiveTurn(ctx, activeTurnId, { - state: "cancelled", - stopReason: "cancelled", + ctx.startedPromptsInFlight > 0 || + (ctx.promptStartedSignal !== undefined && + (yield* Deferred.isDone(ctx.promptStartedSignal))); + if (!promptStarted && ctx.startedPromptsInFlight === 0) { + yield* settlePromptInFlight(ctx, activeTurnId, { + promptStarted: false, + payload: { + state: "cancelled", + stopReason: "cancelled", + }, }); return Option.none>(); } From 04e8bf84e9d61a9a24b6752c673a359ecd4b94f1 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 17:23:55 +0200 Subject: [PATCH 15/20] fix(vibe) follow up handling --- .../src/provider/Layers/VibeAdapter.test.ts | 53 ++- .../server/src/provider/Layers/VibeAdapter.ts | 351 +++++++++--------- 2 files changed, 210 insertions(+), 194 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index c72b5829043ed..6be35ade66a27 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -25,11 +25,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ServerConfig } from "../../config.ts"; -import { - drainVibeEventsUnlessStopped, - makeVibeAdapter, - vibePromptSettlementBelongsToTurn, -} from "./VibeAdapter.ts"; +import { drainVibeEventsUnlessStopped, makeVibeAdapter } from "./VibeAdapter.ts"; const decodeVibeSettings = Schema.decodeSync(VibeSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -97,15 +93,6 @@ function waitForFileOccurrences( return readAttempt(attempts); } -it("requires a prompt settlement to match the active Vibe turn", () => { - const staleTurnId = TurnId.make("stale-turn"); - const replacementTurnId = TurnId.make("replacement-turn"); - - assert.isFalse(vibePromptSettlementBelongsToTurn(undefined, staleTurnId)); - assert.isFalse(vibePromptSettlementBelongsToTurn(replacementTurnId, staleTurnId)); - assert.isTrue(vibePromptSettlementBelongsToTurn(staleTurnId, staleTurnId)); -}); - it.effect("stops waiting for ACP event drainage when the session stops", () => Effect.gen(function* () { const stoppedSignal = yield* Deferred.make(); @@ -257,6 +244,38 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }).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(() => @@ -470,6 +489,12 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { 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); diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 4e2f733f273af..b9e23de7a4bf2 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -71,33 +71,27 @@ interface PendingApproval { readonly decision: Deferred.Deferred; } +interface VibeActiveTurn { + readonly id: TurnId; + started: boolean; + interrupted: boolean; + promptStartedSignal?: Deferred.Deferred; + promptsInFlight: number; + startedPromptsInFlight: number; +} + interface VibeSessionContext { readonly threadId: ThreadId; - readonly acpSessionId: string; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly pendingApprovals: Map; readonly stoppedSignal: Deferred.Deferred; session: ProviderSession; turns: Array<{ id: TurnId; items: Array }>; - readonly interruptedTurnIds: Set; - notificationFiber?: Fiber.Fiber; - activeTurnId?: TurnId; - promptStartedSignal?: Deferred.Deferred; - promptsInFlight: number; - startedPromptsInFlight: number; - turnPreparationInProgress: boolean; - interruptPreparationRequested: boolean; + activeTurn?: VibeActiveTurn; stopped: boolean; } -export function vibePromptSettlementBelongsToTurn( - activeTurnId: TurnId | undefined, - turnId: TurnId, -): boolean { - return activeTurnId === turnId; -} - export function drainVibeEventsUnlessStopped( drainEvents: Effect.Effect, stoppedSignal: Deferred.Deferred, @@ -262,7 +256,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ...(resumedSessionId ? { resumeSessionId: resumedSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, requestLogger: (event) => { - const promptStartedSignal = liveContext?.promptStartedSignal; + const promptStartedSignal = liveContext?.activeTurn?.promptStartedSignal; return event.method === "session/prompt" && event.status === "started" && promptStartedSignal @@ -311,7 +305,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const decision = yield* Deferred.make(); pendingApprovals.set(requestId, { decision }); const live = sessions.get(input.threadId); - const turnId = live?.activeTurnId; + const turnId = live?.activeTurn?.id; yield* publish( makeAcpRequestOpenedEvent({ stamp: yield* stamp(), @@ -403,32 +397,27 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }; const ctx: VibeSessionContext = { threadId: input.threadId, - acpSessionId: started.sessionId, scope: sessionScope, acp, pendingApprovals, stoppedSignal, session, turns: [], - interruptedTurnIds: new Set(), - promptsInFlight: 0, - startedPromptsInFlight: 0, - turnPreparationInProgress: false, - interruptPreparationRequested: false, stopped: false, }; sessions.set(input.threadId, ctx); liveContext = ctx; - ctx.notificationFiber = yield* Stream.runForEach(acp.getEvents(), (event) => + yield* Stream.runForEach(acp.getEvents(), (event) => Effect.gen(function* () { if (event._tag === "EventStreamBarrier") { yield* Deferred.succeed(event.acknowledge, undefined); return; } if (event._tag === "ModeChanged") return; - const turnId = ctx.activeTurnId; - if (!turnId) return; + const activeTurn = ctx.activeTurn; + if (!activeTurn) return; + const turnId = activeTurn.id; const eventStamp = yield* stamp(); switch (event._tag) { case "AssistantItemStarted": @@ -520,34 +509,36 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const startSession: VibeAdapterShape["startSession"] = (input) => withThreadLock(input.threadId, startSessionUnlocked(input)); - const settleActiveTurn = ( - ctx: VibeSessionContext, - turnId: TurnId, - payload: Extract["payload"], - ) => + const resetActiveTurn = (ctx: VibeSessionContext, turn: VibeActiveTurn) => Effect.gen(function* () { - if ( - ctx.stopped || - sessions.get(ctx.threadId) !== ctx || - !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) - ) { + if (ctx.stopped || sessions.get(ctx.threadId) !== ctx || ctx.activeTurn !== turn) { return false; } - delete ctx.activeTurnId; - delete ctx.promptStartedSignal; - ctx.interruptedTurnIds.delete(turnId); + 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; + } yield* publish({ type: "turn.completed", ...(yield* stamp()), provider: PROVIDER, threadId: ctx.threadId, - turnId, + turnId: turn.id, payload, }); return true; @@ -555,7 +546,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const settlePromptInFlight = ( ctx: VibeSessionContext, - turnId: TurnId, + turn: VibeActiveTurn, options?: { readonly promptStarted?: boolean; readonly payload?: Extract["payload"]; @@ -566,18 +557,16 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt return false; } if (options?.promptStarted) { - ctx.startedPromptsInFlight = Math.max(0, ctx.startedPromptsInFlight - 1); + turn.startedPromptsInFlight = Math.max(0, turn.startedPromptsInFlight - 1); } - const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); - ctx.promptsInFlight = remainingPrompts; - if ( - remainingPrompts > 0 || - !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) || - !options?.payload - ) { + turn.promptsInFlight = Math.max(0, turn.promptsInFlight - 1); + if (turn.promptsInFlight > 0 || ctx.activeTurn !== turn) { return false; } - return yield* settleActiveTurn(ctx, turnId, options.payload); + if (!options?.payload) { + return turn.started ? false : yield* resetActiveTurn(ctx, turn); + } + return yield* settleActiveTurn(ctx, turn, options.payload); }); /** @@ -595,140 +584,149 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - const activeTurnId = ctx.activeTurnId; - const steeringTurnId = - activeTurnId && !ctx.interruptedTurnIds.has(activeTurnId) ? activeTurnId : undefined; - if (activeTurnId && steeringTurnId === undefined) { + const activeTurn = ctx.activeTurn; + if (activeTurn?.interrupted) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", issue: "Mistral Vibe already has an active turn.", }); } - ctx.turnPreparationInProgress = true; - ctx.interruptPreparationRequested = false; - 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.", - }); - } - const turnId = steeringTurnId ?? TurnId.make(yield* nextId); - const promptStartedSignal = yield* Deferred.make(); - ctx.promptsInFlight += 1; - ctx.activeTurnId = turnId; - ctx.promptStartedSignal = promptStartedSignal; - if (ctx.interruptPreparationRequested) { - ctx.interruptedTurnIds.add(turnId); - } + // 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, + startedPromptsInFlight: 0, + } satisfies VibeActiveTurn); + turn.promptsInFlight += 1; + ctx.activeTurn = turn; ctx.session = { ...ctx.session, - status: "running", - activeTurnId: turnId, + status: steeringTurn === undefined ? "connecting" : "running", + activeTurnId: turn.id, updatedAt: yield* nowIso, - model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), }; - if (steeringTurnId === undefined) { - yield* publish({ - type: "turn.started", - ...(yield* stamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, + + 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 fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); - yield* Effect.raceFirst( - Deferred.await(promptStartedSignal), - Fiber.await(fiber).pipe(Effect.asVoid), - ); - const promptStarted = yield* Deferred.isDone(promptStartedSignal); - if (promptStarted) { - ctx.startedPromptsInFlight += 1; - } - return { ctx, turnId, prompt, fiber, promptStarted }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - const ctx = sessions.get(input.threadId); - if (ctx) { - ctx.turnPreparationInProgress = false; - ctx.interruptPreparationRequested = false; - } - }), - ), - ), + 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.", + }); + } + ctx.session = { + ...ctx.session, + status: "running", + updatedAt: yield* nowIso, + model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), + }; + if (steeringTurn === undefined) { + yield* publish({ + type: "turn.started", + ...(yield* stamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: turn.id, + payload: { model: ctx.session.model }, + }); + turn.started = true; + } + if (turn.interrupted) { + return { _tag: "Interrupted" as const, ctx, turn }; + } + const promptStartedSignal = yield* Deferred.make(); + turn.promptStartedSignal = promptStartedSignal; + const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); + yield* Effect.raceFirst( + Deferred.await(promptStartedSignal), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + const promptStarted = yield* Deferred.isDone(promptStartedSignal); + if (promptStarted) { + turn.startedPromptsInFlight += 1; + } + return { _tag: "Started" as const, ctx, turn, prompt, fiber, promptStarted }; + }).pipe(Effect.tapCause(() => settlePromptInFlight(ctx, turn))); + }), ); const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const { ctx, turnId, prompt, fiber, promptStarted } = yield* prepareAndStartTurn(input); - if (ctx.interruptedTurnIds.has(turnId)) { + const prepared = yield* prepareAndStartTurn(input); + const { ctx, turn } = prepared; + const turnId = turn.id; + if (prepared._tag === "Interrupted" || turn.interrupted) { yield* withThreadLock( input.threadId, - settlePromptInFlight(ctx, turnId, { - promptStarted, + settlePromptInFlight(ctx, turn, { + promptStarted: prepared._tag === "Started" && prepared.promptStarted, payload: { state: "cancelled", stopReason: "cancelled" }, }), ); return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; } + const { prompt, fiber, promptStarted } = prepared; const result = yield* Fiber.join(fiber).pipe( Effect.tapError((cause) => Effect.gen(function* () { yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); yield* withThreadLock( input.threadId, - settlePromptInFlight(ctx, turnId, { + settlePromptInFlight(ctx, turn, { promptStarted, payload: { state: "failed", @@ -746,12 +744,8 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt yield* withThreadLock( input.threadId, Effect.gen(function* () { - if ( - ctx.stopped || - sessions.get(input.threadId) !== ctx || - !vibePromptSettlementBelongsToTurn(ctx.activeTurnId, turnId) - ) { - yield* settlePromptInFlight(ctx, turnId, { promptStarted }); + if (ctx.stopped || sessions.get(input.threadId) !== ctx || ctx.activeTurn !== turn) { + yield* settlePromptInFlight(ctx, turn, { promptStarted }); return; } const turnRecord = ctx.turns.find((turn) => turn.id === turnId); @@ -760,13 +754,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt } else { ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); } - const wasInterrupted = ctx.interruptedTurnIds.has(turnId); - yield* settlePromptInFlight(ctx, turnId, { + yield* settlePromptInFlight(ctx, turn, { promptStarted, payload: { state: - wasInterrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: wasInterrupted ? "cancelled" : result.stopReason, + turn.interrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: turn.interrupted ? "cancelled" : result.stopReason, }, }); }), @@ -779,33 +772,31 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt yield* Effect.sync(() => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) return; - const activeTurnId = ctx.activeTurnId; - if (turnId && activeTurnId && turnId !== activeTurnId) return; - if (activeTurnId) { - ctx.interruptedTurnIds.add(activeTurnId); - } else if (ctx.turnPreparationInProgress && turnId === undefined) { - ctx.interruptPreparationRequested = true; + const activeTurn = ctx.activeTurn; + if (turnId && activeTurn && turnId !== activeTurn.id) return; + if (activeTurn) { + activeTurn.interrupted = true; } }); const acpCancel = yield* withThreadLock( threadId, Effect.gen(function* () { const ctx = yield* requireSession(threadId); - const activeTurnId = ctx.activeTurnId; - if (turnId && activeTurnId && turnId !== activeTurnId) + const activeTurn = ctx.activeTurn; + if (turnId && activeTurn && turnId !== activeTurn.id) return Option.none>(); yield* Effect.forEach( ctx.pendingApprovals.values(), (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), { discard: true }, ); - if (!activeTurnId) return Option.none>(); + if (!activeTurn) return Option.none>(); const promptStarted = - ctx.startedPromptsInFlight > 0 || - (ctx.promptStartedSignal !== undefined && - (yield* Deferred.isDone(ctx.promptStartedSignal))); - if (!promptStarted && ctx.startedPromptsInFlight === 0) { - yield* settlePromptInFlight(ctx, activeTurnId, { + activeTurn.startedPromptsInFlight > 0 || + (activeTurn.promptStartedSignal !== undefined && + (yield* Deferred.isDone(activeTurn.promptStartedSignal))); + if (!promptStarted && activeTurn.startedPromptsInFlight === 0) { + yield* settlePromptInFlight(ctx, activeTurn, { promptStarted: false, payload: { state: "cancelled", From b15b270e7d18787a8d41b07ea889a0622363b4a7 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 17:55:56 +0200 Subject: [PATCH 16/20] fix(vibe): harden turn cancellation and native event logging - Settle interrupted prompts and prevent stale active turns - Forward native ACP events and deduplicate plan updates - Expand cancellation and provider registry coverage --- .../server/src/provider/Drivers/VibeDriver.ts | 2 + .../provider/Layers/ProviderRegistry.test.ts | 2 + .../src/provider/Layers/VibeAdapter.test.ts | 149 +++++++- .../server/src/provider/Layers/VibeAdapter.ts | 361 +++++++++++------- 4 files changed, 374 insertions(+), 140 deletions(-) diff --git a/apps/server/src/provider/Drivers/VibeDriver.ts b/apps/server/src/provider/Drivers/VibeDriver.ts index 11ef64fa82f28..7e0fb5e0d09a1 100644 --- a/apps/server/src/provider/Drivers/VibeDriver.ts +++ b/apps/server/src/provider/Drivers/VibeDriver.ts @@ -88,6 +88,7 @@ export const VibeDriver: ProviderDriver = { 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, @@ -106,6 +107,7 @@ export const VibeDriver: ProviderDriver = { }); const adapter = yield* makeVibeAdapter(effectiveConfig, { environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), instanceId, }); const textGeneration = yield* makeVibeTextGeneration(effectiveConfig, processEnv); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc6620e..485591975a163 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1514,6 +1514,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + vibe: { enabled: false }, }, }), ), @@ -1764,6 +1765,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 index 6be35ade66a27..1049ab39158c9 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -326,10 +326,14 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { ); yield* adapter.interruptTurn(threadId); - const result = yield* Fiber.join(sendFiber); + const error = yield* Fiber.join(sendFiber).pipe(Effect.flip); yield* Effect.yieldNow; - const requests = yield* waitForFileContent(requestLogPath); + const requests = yield* waitForFileOccurrences( + requestLogPath, + '"method":"session/cancel"', + 1, + ); const started = events.filter( (event) => event.type === "turn.started" && event.threadId === threadId, ); @@ -337,19 +341,23 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { (event): event is Extract => event.type === "turn.completed" && event.threadId === threadId, ); - assert.lengthOf(started, 1); - assert.deepEqual( - completed.map((event) => [event.turnId, event.payload.state]), - [[result.turnId, "cancelled"]], - ); + 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."); + } + assert.lengthOf(started, 0); + assert.lengthOf(completed, 1); + assert.equal(completed[0]?.payload.state, "cancelled"); assert.notInclude(requests, '"method":"session/prompt"'); + assert.include(requests, '"method":"session/cancel"'); yield* Fiber.interrupt(eventsFiber); yield* adapter.stopSession(threadId); }).pipe(Effect.scoped, Effect.timeout("5 seconds"), TestClock.withLive), ); - it.effect("keeps a cancelled prompt as the active turn until Vibe settles it", () => + 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-")), @@ -414,14 +422,135 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { [followUp.turnId, "completed"], ], ); - assert.equal(lateDelta?.turnId, firstTurnId); - assert.notEqual(lateDelta?.turnId, followUp.turnId); + 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(() => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index b9e23de7a4bf2..33ebc27e28e44 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -16,11 +16,11 @@ 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 Fiber from "effect/Fiber"; 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"; @@ -47,6 +47,7 @@ import { 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 { @@ -58,12 +59,22 @@ import { 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; } @@ -75,9 +86,7 @@ interface VibeActiveTurn { readonly id: TurnId; started: boolean; interrupted: boolean; - promptStartedSignal?: Deferred.Deferred; promptsInFlight: number; - startedPromptsInFlight: number; } interface VibeSessionContext { @@ -88,6 +97,7 @@ interface VibeSessionContext { readonly stoppedSignal: Deferred.Deferred; session: ProviderSession; turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; activeTurn?: VibeActiveTurn; stopped: boolean; } @@ -111,7 +121,16 @@ function selectPermissionOptionId( : decision === "accept" ? "allow_once" : "reject_once"; - return request.options.find((option) => option.kind === kind)?.optionId; + 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 { @@ -132,6 +151,15 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt 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(); @@ -189,6 +217,68 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ), ); + 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; @@ -223,7 +313,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", - issue: "cwd is required.", + issue: "cwd is required and must be non-empty.", }); } const existing = sessions.get(input.threadId); @@ -231,8 +321,8 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt yield* stopContext(existing); } - const cwd = path.resolve(input.cwd); - const sessionScope = yield* Scope.make(); + const cwd = path.resolve(input.cwd.trim()); + const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => sessionScopeTransferred @@ -247,7 +337,11 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const modelSelection = input.modelSelection?.instanceId === instanceId ? input.modelSelection : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - let liveContext: VibeSessionContext | undefined; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); const acp = yield* makeVibeAcpRuntime({ vibeSettings: settings, ...(options?.environment ? { environment: options.environment } : {}), @@ -255,14 +349,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt cwd, ...(resumedSessionId ? { resumeSessionId: resumedSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, - requestLogger: (event) => { - const promptStartedSignal = liveContext?.activeTurn?.promptStartedSignal; - return event.method === "session/prompt" && - event.status === "started" && - promptStartedSignal - ? Deferred.succeed(promptStartedSignal, undefined).pipe(Effect.asVoid) - : Effect.void; - }, + ...acpNativeLoggers, ...(mcpSession ? { mcpServers: [ @@ -292,11 +379,10 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt yield* acp.handleRequestPermission((params) => callbackError( Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); if (input.runtimeMode === "full-access") { - const autoOption = - selectPermissionOptionId(params, "acceptForSession") ?? - selectPermissionOptionId(params, "accept"); - if (autoOption) { + const autoOption = selectAutoApprovedPermissionOption(params); + if (autoOption !== undefined) { return { outcome: { outcome: "selected" as const, optionId: autoOption } }; } } @@ -314,7 +400,10 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt turnId, requestId: RuntimeRequestId.make(requestId), permissionRequest, - detail: permissionRequest.detail ?? "Mistral Vibe requests permission.", + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "Mistral Vibe requests permission.", args: params, source: "acp.jsonrpc", method: "session/request_permission", @@ -403,10 +492,10 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt stoppedSignal, session, turns: [], + lastPlanFingerprint: undefined, stopped: false, }; sessions.set(input.threadId, ctx); - liveContext = ctx; yield* Stream.runForEach(acp.getEvents(), (event) => Effect.gen(function* () { @@ -414,6 +503,13 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt 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; @@ -435,18 +531,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ); return; case "PlanUpdated": - yield* publish( - makeAcpPlanUpdatedEvent({ - stamp: eventStamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId, - payload: event.payload, - source: "acp.jsonrpc", - method: "session/update", - rawPayload: event.rawPayload, - }), - ); + yield* emitPlanUpdate(ctx, turnId, eventStamp, event.payload, event.rawPayload); return; case "ToolCallUpdated": yield* publish( @@ -548,18 +633,17 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ctx: VibeSessionContext, turn: VibeActiveTurn, options?: { - readonly promptStarted?: boolean; readonly payload?: Extract["payload"]; + readonly settleAllPrompts?: boolean; }, ) => Effect.gen(function* () { if (ctx.stopped || sessions.get(ctx.threadId) !== ctx) { return false; } - if (options?.promptStarted) { - turn.startedPromptsInFlight = Math.max(0, turn.startedPromptsInFlight - 1); - } - turn.promptsInFlight = Math.max(0, turn.promptsInFlight - 1); + turn.promptsInFlight = options?.settleAllPrompts + ? 0 + : Math.max(0, turn.promptsInFlight - 1); if (turn.promptsInFlight > 0 || ctx.activeTurn !== turn) { return false; } @@ -576,8 +660,9 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt * 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 started while still holding - * the lock to reduce race conditions with rapid successive messages. + * 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( @@ -585,13 +670,6 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); const activeTurn = ctx.activeTurn; - if (activeTurn?.interrupted) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Mistral Vibe already has an active turn.", - }); - } // 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; @@ -602,7 +680,6 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt started: false, interrupted: false, promptsInFlight: 0, - startedPromptsInFlight: 0, } satisfies VibeActiveTurn); turn.promptsInFlight += 1; ctx.activeTurn = turn; @@ -667,6 +744,14 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt issue: "Turn requires non-empty text or attachments.", }); } + if (turn.interrupted) { + turn.promptsInFlight = 0; + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Mistral Vibe prompt was interrupted during preparation.", + }); + } ctx.session = { ...ctx.session, status: "running", @@ -674,6 +759,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt model: resolveVibeModelId(modelSelection?.model ?? ctx.session.model), }; if (steeringTurn === undefined) { + ctx.lastPlanFingerprint = undefined; yield* publish({ type: "turn.started", ...(yield* stamp()), @@ -684,87 +770,88 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }); turn.started = true; } - if (turn.interrupted) { - return { _tag: "Interrupted" as const, ctx, turn }; - } - const promptStartedSignal = yield* Deferred.make(); - turn.promptStartedSignal = promptStartedSignal; - const fiber = yield* ctx.acp.prompt({ prompt }).pipe(Effect.forkIn(ctx.scope)); - yield* Effect.raceFirst( - Deferred.await(promptStartedSignal), - Fiber.await(fiber).pipe(Effect.asVoid), - ); - const promptStarted = yield* Deferred.isDone(promptStartedSignal); - if (promptStarted) { - turn.startedPromptsInFlight += 1; - } - return { _tag: "Started" as const, ctx, turn, prompt, fiber, promptStarted }; - }).pipe(Effect.tapCause(() => settlePromptInFlight(ctx, turn))); + return { ctx, turn, prompt }; + }).pipe( + Effect.tapCause(() => + turn.interrupted && turn.promptsInFlight === 0 + ? Effect.void + : settlePromptInFlight(ctx, turn), + ), + ); }), ); const sendTurn: VibeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - const prepared = yield* prepareAndStartTurn(input); - const { ctx, turn } = prepared; + const { ctx, turn, prompt } = yield* prepareAndStartTurn(input); const turnId = turn.id; - if (prepared._tag === "Interrupted" || turn.interrupted) { + 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, - settlePromptInFlight(ctx, turn, { - promptStarted: prepared._tag === "Started" && prepared.promptStarted, - payload: { state: "cancelled", stopReason: "cancelled" }, + 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 }; - } - - const { prompt, fiber, promptStarted } = prepared; - const result = yield* Fiber.join(fiber).pipe( - Effect.tapError((cause) => - Effect.gen(function* () { - yield* drainVibeEventsUnlessStopped(ctx.acp.drainEvents, ctx.stoppedSignal); - yield* withThreadLock( - input.threadId, - settlePromptInFlight(ctx, turn, { - promptStarted, - payload: { - state: "failed", - errorMessage: cause.message || "Mistral Vibe prompt request failed.", - }, - }), - ); - }).pipe(Effect.catchCause(() => Effect.void)), - ), - Effect.mapError((cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), + }).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)), ), ); - 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, { promptStarted }); - return; - } - const turnRecord = ctx.turns.find((turn) => turn.id === turnId); - if (turnRecord) { - turnRecord.items.push({ prompt, result }); - } else { - ctx.turns.push({ id: turnId, items: [{ prompt, result }] }); - } - yield* settlePromptInFlight(ctx, turn, { - promptStarted, - payload: { - state: - turn.interrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: turn.interrupted ? "cancelled" : result.stopReason, - }, - }); - }), - ); - return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; }); const interruptTurn: VibeAdapterShape["interruptTurn"] = (threadId, turnId) => @@ -783,29 +870,36 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt Effect.gen(function* () { const ctx = yield* requireSession(threadId); const activeTurn = ctx.activeTurn; - if (turnId && activeTurn && turnId !== activeTurn.id) - return Option.none>(); + if (turnId && activeTurn && 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) return Option.none>(); - const promptStarted = - activeTurn.startedPromptsInFlight > 0 || - (activeTurn.promptStartedSignal !== undefined && - (yield* Deferred.isDone(activeTurn.promptStartedSignal))); - if (!promptStarted && activeTurn.startedPromptsInFlight === 0) { + if (activeTurn) { yield* settlePromptInFlight(ctx, activeTurn, { - promptStarted: false, payload: { state: "cancelled", stopReason: "cancelled", }, + settleAllPrompts: true, }); - return Option.none>(); } - return Option.some(ctx.acp.cancel.pipe(Effect.ignore)); + // 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, { @@ -867,9 +961,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt Effect.suspend(() => Effect.flatMap(requireSession(threadId), stopContext)), ); const listSessions: VibeAdapterShape["listSessions"] = () => - Effect.succeed(Array.from(sessions.values(), (ctx) => ctx.session)); + Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session }))); const hasSession: VibeAdapterShape["hasSession"] = (threadId) => - Effect.succeed(sessions.has(threadId)); + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); const stopAll: VibeAdapterShape["stopAll"] = () => Effect.forEach( Array.from(sessions.values()), @@ -884,8 +981,12 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ); 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), ), ); From c26075309d552ae4b06b142a7ff3875623476cdb Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 18:03:58 +0200 Subject: [PATCH 17/20] fix(vibe): avoid terminal events for pre-start cancellation - Reset cancelled sessions silently when a turn never started - Update coverage for ready session state and absent completion events --- apps/server/src/provider/Layers/VibeAdapter.test.ts | 9 +++++++-- apps/server/src/provider/Layers/VibeAdapter.ts | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 1049ab39158c9..2fd8150e1e72f 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -346,12 +346,17 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { 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, 1); - assert.equal(completed[0]?.payload.state, "cancelled"); + 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), diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 33ebc27e28e44..23bba29c1a0c1 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -618,6 +618,13 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt 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()), From 4214b3e76048047e43e8bfec8454e00c406eea66 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 18:23:37 +0200 Subject: [PATCH 18/20] fix(vibe): preserve prompt reservations during interruption - Settle only the interrupted prompt reservation - Ignore targeted cancels for missing or stale turns --- .../server/src/provider/Layers/VibeAdapter.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index 23bba29c1a0c1..fb6f5b48a9ff0 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -752,7 +752,11 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt }); } if (turn.interrupted) { - turn.promptsInFlight = 0; + // 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", @@ -778,13 +782,7 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt turn.started = true; } return { ctx, turn, prompt }; - }).pipe( - Effect.tapCause(() => - turn.interrupted && turn.promptsInFlight === 0 - ? Effect.void - : settlePromptInFlight(ctx, turn), - ), - ); + }).pipe(Effect.tapCause(() => settlePromptInFlight(ctx, turn))); }), ); @@ -867,17 +865,18 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) return; const activeTurn = ctx.activeTurn; - if (turnId && activeTurn && turnId !== activeTurn.id) return; - if (activeTurn) { - activeTurn.interrupted = true; - } + 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; - if (turnId && activeTurn && turnId !== activeTurn.id) { + // 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( From 637b4fcf0e590d1bf562dd292c6c0fe8e415caf7 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 21:03:50 +0200 Subject: [PATCH 19/20] fix(vibe): handle missing workspace trust decisions - Avoid failing session startup when Vibe has no pending trust prompt - Select an available trust decision and cover both cases in tests --- apps/server/scripts/acp-mock-agent.ts | 30 ++++++++- .../src/provider/Layers/VibeAdapter.test.ts | 65 +++++++++++++++++++ .../server/src/provider/Layers/VibeAdapter.ts | 40 ++++++++---- .../server/src/provider/acp/VibeAcpSupport.ts | 34 +++++++++- 4 files changed, 153 insertions(+), 16 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 59b919adc68d9..c6827cd57739f 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -23,6 +23,12 @@ const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLET 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"; @@ -959,9 +965,31 @@ const program = Effect.gen(function* () { yield* agent.handleUnknownExtRequest((method, params) => { if (method === "_trust/status") { - return Effect.succeed({ trust_status: vibeTrustStatus, details: null }); + 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 }); } diff --git a/apps/server/src/provider/Layers/VibeAdapter.test.ts b/apps/server/src/provider/Layers/VibeAdapter.test.ts index 2fd8150e1e72f..ac816b022b135 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.test.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.test.ts @@ -212,6 +212,71 @@ it.layer(vibeAdapterTestLayer)("VibeAdapter", (it) => { }), ); + 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(() => diff --git a/apps/server/src/provider/Layers/VibeAdapter.ts b/apps/server/src/provider/Layers/VibeAdapter.ts index fb6f5b48a9ff0..f0e7172874369 100644 --- a/apps/server/src/provider/Layers/VibeAdapter.ts +++ b/apps/server/src/provider/Layers/VibeAdapter.ts @@ -55,6 +55,7 @@ import { makeVibeAcpRuntime, readVibeWorkspaceTrust, resolveVibeModelId, + selectVibeTrustDecision, trustVibeWorkspace, vibeModeForRuntimeMode, } from "../acp/VibeAcpSupport.ts"; @@ -445,20 +446,33 @@ export function makeVibeAdapter(settings: VibeSettings, options?: VibeAdapterOpt ), ); if (trust.trust_status === "untrusted" && settings.trustWorkspace) { - const trusted = yield* trustVibeWorkspace(acp, { - cwd, - sessionId: started.sessionId, - }).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.", + 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.", + }); + } } } diff --git a/apps/server/src/provider/acp/VibeAcpSupport.ts b/apps/server/src/provider/acp/VibeAcpSupport.ts index 72e1c51581605..b804ab132a934 100644 --- a/apps/server/src/provider/acp/VibeAcpSupport.ts +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -46,6 +46,32 @@ const WorkspaceTrustStatusResponse = Schema.Struct({ export type WorkspaceTrustStatusResponse = typeof WorkspaceTrustStatusResponse.Type; const decodeWorkspaceTrustStatusResponse = Schema.decodeUnknownEffect(WorkspaceTrustStatusResponse); +export type VibeWorkspaceTrustDecision = "trust_repo" | "trust_cwd" | "trust_session"; + +/** Broadest grant first: trusting the repo covers later sessions in any subdir. */ +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, @@ -194,13 +220,17 @@ export const readVibeWorkspaceTrust = ( export const trustVibeWorkspace = ( runtime: Pick, - input: { readonly cwd: string; readonly sessionId: string }, + input: { + readonly cwd: string; + readonly sessionId: string; + readonly decision?: VibeWorkspaceTrustDecision; + }, ): Effect.Effect => runtime .request("_trust/decision", { cwd: input.cwd, sessionId: input.sessionId, - decision: "trust_cwd", + decision: input.decision ?? "trust_cwd", }) .pipe( Effect.flatMap((response) => From 2332eafc7292db40c9b47a272feb93aeaba998c9 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Tue, 28 Jul 2026 21:15:15 +0200 Subject: [PATCH 20/20] fix(vibe): prefer workspace-scoped trust grants - Avoid automatically trusting sibling directories in the repository --- apps/server/src/provider/acp/VibeAcpSupport.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/VibeAcpSupport.ts b/apps/server/src/provider/acp/VibeAcpSupport.ts index b804ab132a934..ad7229ec3d5a6 100644 --- a/apps/server/src/provider/acp/VibeAcpSupport.ts +++ b/apps/server/src/provider/acp/VibeAcpSupport.ts @@ -48,7 +48,15 @@ const decodeWorkspaceTrustStatusResponse = Schema.decodeUnknownEffect(WorkspaceT export type VibeWorkspaceTrustDecision = "trust_repo" | "trust_cwd" | "trust_session"; -/** Broadest grant first: trusting the repo covers later sessions in any subdir. */ +/** + * 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",