From d049c4ebf760cdacc5d6e04913c2936047a2c7c9 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 09:20:36 +0100 Subject: [PATCH 01/18] feat(server): add native Mistral Vibe provider via vibe-app-server Reworks #4720 onto current main against the V2 adapter contract instead of the deleted V1 adapter/ACP path: a new @t3tools/vibe-app-server package speaks vibe-app-server's newline-delimited JSON-RPC protocol directly (like codex-app-server), and a new @t3tools/provider-vibe package builds the driver, orchestration-v2 adapter, status probe, and text generation on it. - Sessions start in the thread's checkout (worktree: existing + cwd), with workspace/git/checkouts reported natively - account/read billing budget surfaces as native usageLimits on the provider snapshot (monthly window + reset), and turn error -31001 maps to usage_limit - config/read models and thinking levels populate the model picker - registered as a built-in driver, provider client (Mistral icon, Early Access), settings secrets, and managed-policy known drivers Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- apps/server/package.json | 1 + apps/server/src/policy/managedProviders.ts | 2 + apps/server/src/provider/builtInDrivers.ts | 5 +- apps/server/src/settingsSecrets.ts | 2 + package.json | 2 +- packages/client-runtime/package.json | 1 + .../client-runtime/src/providerClients.ts | 2 + packages/contracts/src/model.ts | 4 + packages/provider-vibe/package.json | 35 + packages/provider-vibe/src/client.ts | 32 + packages/provider-vibe/src/server.ts | 2 + packages/provider-vibe/src/server/adapter.ts | 809 ++++++++++++++++++ .../src/server/appServerSupport.test.ts | 99 +++ .../src/server/appServerSupport.ts | 180 ++++ packages/provider-vibe/src/server/driver.ts | 164 ++++ .../provider-vibe/src/server/status.test.ts | 69 ++ packages/provider-vibe/src/server/status.ts | 264 ++++++ .../src/server/textGeneration.ts | 136 +++ .../src/server/usageLimits.test.ts | 45 + .../provider-vibe/src/server/usageLimits.ts | 40 + packages/provider-vibe/src/settings.ts | 80 ++ packages/provider-vibe/tsconfig.json | 7 + packages/vibe-app-server/package.json | 32 + packages/vibe-app-server/src/client.test.ts | 134 +++ packages/vibe-app-server/src/client.ts | 621 ++++++++++++++ packages/vibe-app-server/src/errors.ts | 61 ++ packages/vibe-app-server/src/live.test.ts | 78 ++ packages/vibe-app-server/src/protocol.ts | 346 ++++++++ packages/vibe-app-server/tsconfig.json | 7 + pnpm-lock.yaml | 53 ++ scripts/release-smoke.ts | 2 + 31 files changed, 3313 insertions(+), 2 deletions(-) create mode 100644 packages/provider-vibe/package.json create mode 100644 packages/provider-vibe/src/client.ts create mode 100644 packages/provider-vibe/src/server.ts create mode 100644 packages/provider-vibe/src/server/adapter.ts create mode 100644 packages/provider-vibe/src/server/appServerSupport.test.ts create mode 100644 packages/provider-vibe/src/server/appServerSupport.ts create mode 100644 packages/provider-vibe/src/server/driver.ts create mode 100644 packages/provider-vibe/src/server/status.test.ts create mode 100644 packages/provider-vibe/src/server/status.ts create mode 100644 packages/provider-vibe/src/server/textGeneration.ts create mode 100644 packages/provider-vibe/src/server/usageLimits.test.ts create mode 100644 packages/provider-vibe/src/server/usageLimits.ts create mode 100644 packages/provider-vibe/src/settings.ts create mode 100644 packages/provider-vibe/tsconfig.json create mode 100644 packages/vibe-app-server/package.json create mode 100644 packages/vibe-app-server/src/client.test.ts create mode 100644 packages/vibe-app-server/src/client.ts create mode 100644 packages/vibe-app-server/src/errors.ts create mode 100644 packages/vibe-app-server/src/live.test.ts create mode 100644 packages/vibe-app-server/src/protocol.ts create mode 100644 packages/vibe-app-server/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 3a6797d90da28..e865f8d7b39f3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-vibe": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 18877c6b5424d..ddc9bc34dc0bf 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -23,6 +23,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { vibeClient } from "@t3tools/provider-vibe/client"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; interface KnownDriver { @@ -50,6 +51,7 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ museClient, openCodeClient, piClient, + vibeClient, acpRegistryClient, ]; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..73cd56a5839db 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -11,6 +11,7 @@ import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; +import { VibeDriver, type VibeDriverEnv } from "@t3tools/provider-vibe/server"; import type { AnyProviderDriver, ProviderUsageReaderEnv, @@ -28,7 +29,8 @@ export type BuiltInDriversEnv = | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv - | MuseDriverEnv; + | MuseDriverEnv + | VibeDriverEnv; export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, @@ -42,6 +44,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray diff --git a/package.json b/package.json index f6731683bbc37..de9fafbedc652 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "knip": "knip --preprocessor ./scripts/knip-schemas.ts", "knip:check": "vp run knip:check:all", "knip:check:files": "knip --include files,dependencies --no-config-hints", - "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-vibe --workspace packages/vibe-app-server --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f52115577f108..0ebffa2a07ef6 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -556,6 +556,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-vibe": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index e23200653b963..e7c980ad00be4 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -15,6 +15,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { vibeClient } from "@t3tools/provider-vibe/client"; export const providerClients = makeProviderClientRegistry([ { @@ -35,6 +36,7 @@ export const providerClients = makeProviderClientRegistry([ grokClient, fxClient, openCodeClient, + vibeClient, { driverKind: ProviderDriverKind.make("antigravity"), label: "Antigravity", diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..c8b4f05abf53f 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -124,6 +124,7 @@ const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const VIBE_DRIVER_KIND = ProviderDriverKind.make("vibe"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -155,6 +156,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", + [VIBE_DRIVER_KIND]: "Mistral Vibe", }; diff --git a/packages/provider-vibe/package.json b/packages/provider-vibe/package.json new file mode 100644 index 0000000000000..d2198eb17c1d4 --- /dev/null +++ b/packages/provider-vibe/package.json @@ -0,0 +1,35 @@ +{ + "name": "@t3tools/provider-vibe", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/vibe-app-server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-vibe/src/client.ts b/packages/provider-vibe/src/client.ts new file mode 100644 index 0000000000000..d86b100ceea75 --- /dev/null +++ b/packages/provider-vibe/src/client.ts @@ -0,0 +1,32 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { VibeSettings } from "./settings.ts"; + +export const vibeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("vibe"), + label: "Mistral Vibe", + modelFamilies: ["Mistral ", "Devstral"], + acpRegistryAgentIds: ["mistral-vibe"], + settingsSchema: VibeSettings, + badgeLabel: "Early Access", + environmentFields: [ + { + name: "MISTRAL_API_KEY", + label: "Mistral API key", + description: + "Optional. La Plateforme API key used by vibe-acp instead of an interactive login.", + placeholder: "Paste API key", + sensitive: true, + }, + ], + icon: { + viewBox: "0 0 24 24", + fill: { light: "#FF7000", dark: "#FF7000" }, + paths: [ + { + d: "M3 3h4v4H3V3Zm14 0h4v4h-4V3ZM3 7h8v4H3V7Zm10 0h8v4h-8V7ZM3 11h18v10h-4v-6h-3v4h-4v-4H7v6H3V11Z", + }, + ], + }, +}); diff --git a/packages/provider-vibe/src/server.ts b/packages/provider-vibe/src/server.ts new file mode 100644 index 0000000000000..9d59a0af78663 --- /dev/null +++ b/packages/provider-vibe/src/server.ts @@ -0,0 +1,2 @@ +export { VibeDriver, type VibeDriverEnv } from "./server/driver.ts"; +export { type VibeAdapterV2Env } from "./server/adapter.ts"; diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts new file mode 100644 index 0000000000000..c33e924f5f957 --- /dev/null +++ b/packages/provider-vibe/src/server/adapter.ts @@ -0,0 +1,809 @@ +import { + NodeId, + ProviderInstanceId, + ProviderTurnId, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as VibeAppServer from "@t3tools/vibe-app-server/client"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { + MISTRAL_VIBE_RATE_LIMITED, + VIBE_PROVIDER, + buildVibeSpawnInput, + resolveVibeModelId, + vibeAgentForRuntimeMode, + vibeWorktreeInputForCwd, +} from "./appServerSupport.ts"; + +const VibeProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: true, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: true, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + canForkThread: true, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: true, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: true, + emitsSubagentLifecycle: true, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "strong", + nativeItemIds: "weak", + nativeRequestIds: "weak", + }, + runtimePolicy: { enforcement: "native" }, +} as const satisfies OrchestrationV2ProviderCapabilities; + +export interface VibeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: VibeSettings; + readonly environment: NodeJS.ProcessEnv; +} + +type VibeClient = VibeAppServer.VibeAppServerClient["Service"]; + +const nativeRef = (nativeId: string) => ({ + driver: VIBE_PROVIDER, + nativeId, + strength: "strong" as const, +}); + +const utcFromMillis = (millis: number | null | undefined): DateTime.Utc | null => + millis === undefined || millis === null ? null : Option.getOrNull(DateTime.make(millis)); + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const str = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + +function entryText(entry: unknown): string { + const record = asRecord(entry); + const content = record?.["content"]; + if (!Array.isArray(content)) return ""; + return content + .map((block) => { + const b = asRecord(block); + return b?.["type"] === "text" && typeof b["text"] === "string" ? b["text"] : ""; + }) + .join(""); +} + +/** Maps a vibe turn error/stop reason to the native failure contract; `-31001` is Vibe's rate limit. */ +export function vibeTurnFailure(turn: { + readonly status?: unknown; + readonly error?: unknown; + readonly stopReason?: unknown; +}): ReturnType | undefined { + const error = asRecord(turn.error); + const code = error?.["code"]; + const message = str(error?.["message"]) ?? str(turn.stopReason) ?? "Mistral Vibe turn failed."; + return makeProviderFailure({ + message, + ...(typeof code === "number" || typeof code === "string" + ? { + code: String(code), + class: + code === MISTRAL_VIBE_RATE_LIMITED + ? ("usage_limit" as const) + : ("provider_error" as const), + } + : { class: "provider_error" as const }), + }); +} + +interface AdapterState { + session: OrchestrationV2ProviderSession; + thread: OrchestrationV2ProviderThread | undefined; + activeTurn: + | { readonly providerTurnId: ProviderTurnId; readonly nativeTurnId: string } + | undefined; + turnOrdinals: Map; + lastRunOrdinal: number; +} + +export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( + options: VibeAdapterV2Options, +) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + const random = yield* SecureRandom.SecureRandom; + + const protocolError = (cause: unknown) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: VIBE_PROVIDER, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const openSessionInner = Effect.fn("VibeAdapterV2.openSession")(function* ( + input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, + ) { + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; + const spawn = buildVibeSpawnInput(options.settings, cwd, options.environment); + const child = yield* spawner + .spawn( + ChildProcess.make(spawn.command, [...spawn.args], { + ...(spawn.cwd !== undefined ? { cwd: spawn.cwd } : {}), + ...(spawn.env !== undefined ? { env: spawn.env } : {}), + }), + ) + .pipe(Effect.mapError(protocolError)); + const client: VibeClient = Context.get( + yield* Layer.build(VibeAppServer.layerChildProcess(child)), + VibeAppServer.VibeAppServerClient, + ); + yield* Effect.mapError( + client.initialize({ + clientName: "t3-code", + clientVersion: "0.0.0", + capabilities: { + callbackKinds: ["approval", "user_input"], + clientTools: ["filesystem/read", "filesystem/write", "terminal"], + }, + }), + protocolError, + ); + yield* Effect.mapError(client.initialized(), protocolError); + + const events = yield* PubSub.unbounded(); + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => + Effect.asVoid(PubSub.publish(events, event)); + + const now = yield* DateTime.now; + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: options.instanceId, + }); + const state: AdapterState = { + session: { + id: providerSessionId, + driver: VIBE_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model || null, + capabilities: VibeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + thread: undefined, + activeTurn: undefined, + turnOrdinals: new Map(), + lastRunOrdinal: 0, + }; + + const sessionIdOf = () => state.thread?.nativeThreadRef?.nativeId ?? undefined; + + const updateThread = Effect.fnUntraced(function* ( + patch: Partial, + ) { + if (!state.thread) return; + state.thread = { ...state.thread, ...patch, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_thread.updated", + driver: VIBE_PROVIDER, + providerThread: state.thread, + }); + }); + + // Translate app-server notifications into V2 projection events. + yield* client.handleUnknownServerNotification( + Effect.fn("VibeAdapterV2.onNotification")(function* (method: string, params: unknown) { + const record = asRecord(params) ?? {}; + const notificationSessionId = str(record["sessionId"]); + const currentSessionId = sessionIdOf(); + if ( + notificationSessionId !== undefined && + currentSessionId !== undefined && + notificationSessionId !== currentSessionId + ) { + return; + } + if (method === "turn/started") { + const turn = asRecord(record["turn"]); + const nativeTurnId = str(turn?.["id"]); + if (!nativeTurnId || !state.thread) return; + const ordinal = (state.turnOrdinals.get(nativeTurnId) ?? state.turnOrdinals.size) + 1; + state.turnOrdinals.set(nativeTurnId, ordinal); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: VIBE_PROVIDER, + nativeTurnId, + }); + state.activeTurn = { providerTurnId, nativeTurnId }; + const startedAt = yield* DateTime.now; + const providerTurn: OrchestrationV2ProviderTurn = { + id: providerTurnId, + providerThreadId: state.thread.id, + nodeId: state.thread.ownerNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), + runAttemptId: null, + nativeTurnRef: nativeRef(nativeTurnId), + ordinal, + status: "running", + startedAt, + completedAt: null, + }; + yield* emit({ + type: "provider_turn.updated", + driver: VIBE_PROVIDER, + providerTurn, + }); + return; + } + if ( + method === "turn/completed" || + method === "turn/interrupted" || + method === "turn/failed" + ) { + const turn = asRecord(record["turn"]); + const nativeTurnId = str(turn?.["id"]); + if (!nativeTurnId || !state.activeTurn || !state.thread) return; + if (state.activeTurn.nativeTurnId !== nativeTurnId) return; + const { providerTurnId } = state.activeTurn; + const completedAt = yield* DateTime.now; + const status = str(turn?.["status"]) ?? method.replace("turn/", ""); + const terminalStatus = + status === "failed" || status === "cancelled" + ? "failed" + : status === "interrupted" + ? "interrupted" + : "completed"; + yield* emit({ + type: "provider_turn.updated", + driver: VIBE_PROVIDER, + providerTurn: { + id: providerTurnId, + providerThreadId: state.thread.id, + nodeId: state.thread.ownerNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), + runAttemptId: null, + nativeTurnRef: nativeRef(nativeTurnId), + ordinal: state.turnOrdinals.get(nativeTurnId) ?? 1, + status: terminalStatus === "failed" ? "failed" : terminalStatus, + startedAt: null, + completedAt, + }, + }); + const failure = + terminalStatus === "failed" + ? vibeTurnFailure({ + status, + error: turn?.["error"], + stopReason: turn?.["stopReason"], + }) + : undefined; + if (failure === undefined) { + yield* emit({ + type: "turn.terminal", + driver: VIBE_PROVIDER, + providerThreadId: state.thread.id, + providerTurnId, + runOrdinal: Math.max(1, state.lastRunOrdinal), + status: terminalStatus === "failed" ? "cancelled" : terminalStatus, + failure: null, + threadDisposition: "reusable", + }); + } else { + yield* emit({ + type: "turn.terminal", + driver: VIBE_PROVIDER, + providerThreadId: state.thread.id, + providerTurnId, + runOrdinal: Math.max(1, state.lastRunOrdinal), + failureItemOrdinal: 1, + status: "failed", + failure, + threadDisposition: "reusable", + }); + } + state.activeTurn = undefined; + return; + } + if (method === "history/entryAdded" || method === "history/entryUpdated") { + const entry = asRecord(record["entry"]); + if (!entry || !state.thread) return; + const role = str(entry["role"]); + const text = entryText(entry); + if (!text) return; + const nativeItemId = str(entry["id"]) ?? `${method}:${yield* random.uuidV4()}`; + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }); + const timestamp = yield* DateTime.now; + const message: OrchestrationV2ConversationMessage = { + createdBy: role === "user" ? "user" : "agent", + creationSource: "provider", + id: messageId, + threadId: input.threadId, + runId: null, + nodeId: null, + role: role === "user" ? "user" : "assistant", + text, + attachments: [], + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + }; + const item = { + id: turnItemId, + threadId: input.threadId, + runId: null, + nodeId: null, + providerThreadId: state.thread.id, + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeItemRef: nativeRef(nativeItemId), + parentItemId: null, + ordinal: 0, + status: "completed" as const, + title: null, + startedAt: null, + completedAt: timestamp, + updatedAt: timestamp, + type: role === "user" ? ("user_message" as const) : ("assistant_message" as const), + messageId, + text, + textOmitted: true as const, + ...(role === "user" + ? { inputIntent: "turn_start" as const, attachments: [] as const } + : { streaming: false as const }), + }; + yield* emit({ type: "message.updated", driver: VIBE_PROVIDER, message }); + yield* emit({ + type: "turn_item.updated", + driver: VIBE_PROVIDER, + turnItem: item as OrchestrationV2TurnItem, + }); + return; + } + }), + ); + + const ensureThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["ensureThread"] = Effect.fn( + "VibeAdapterV2.ensureThread", + )(function* (ensureInput) { + const agent = vibeAgentForRuntimeMode( + ensureInput.runtimePolicy.runtimeMode, + ensureInput.runtimePolicy.interactionMode, + ); + const threadCwd = ensureInput.runtimePolicy.cwd ?? cwd; + const nativeId = ensureInput.existingProviderThread?.nativeThreadRef?.nativeId ?? undefined; + const agentConfig = { + cwd: threadCwd, + headless: true, + trustWorkspace: options.settings.trustWorkspace, + worktree: vibeWorktreeInputForCwd(threadCwd), + agent: agent.agent, + autoApprove: agent.autoApprove, + }; + const started = nativeId + ? yield* Effect.mapError( + client.sessionResume({ sessionId: nativeId, agentConfig }), + protocolError, + ) + : yield* Effect.mapError( + client.sessionStart({ + agentConfig, + ...(ensureInput.title ? { title: ensureInput.title } : {}), + ...(ensureInput.modelSelection.model + ? { + model: resolveVibeModelId(ensureInput.modelSelection.model), + sessionOnly: true, + } + : {}), + }), + protocolError, + ); + const nativeThreadId = started.state.session.id; + const createdAt = yield* DateTime.now; + const thread: OrchestrationV2ProviderThread = { + id: idAllocator.derive.providerThread({ + driver: VIBE_PROVIDER, + providerInstanceId: options.instanceId, + nativeThreadId, + }), + driver: VIBE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId, + appThreadId: ensureInput.threadId, + ownerNodeId: null, + nativeThreadRef: nativeRef(nativeThreadId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + updatedAt: createdAt, + }; + state.thread = thread; + yield* emit({ + type: "provider_thread.updated", + driver: VIBE_PROVIDER, + providerThread: thread, + }); + return thread; + }); + + const startTurn: ProviderAdapter.ProviderAdapterV2SessionRuntime["startTurn"] = Effect.fn( + "VibeAdapterV2.startTurn", + )(function* (turnInput) { + const nativeSessionId = sessionIdOf(); + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: VIBE_PROVIDER, + detail: "No Mistral Vibe session owns this thread.", + }); + } + state.lastRunOrdinal = turnInput.runOrdinal; + yield* Effect.mapError( + client.configModelWrite({ + sessionId: nativeSessionId, + modelAlias: resolveVibeModelId(turnInput.modelSelection.model), + sessionOnly: true, + }), + protocolError, + ).pipe(Effect.ignore); + yield* Effect.mapError( + client.sessionTurnEnqueue({ + sessionId: nativeSessionId, + entries: [{ role: "user", content: [{ type: "text", text: turnInput.message.text }] }], + }), + protocolError, + ); + yield* updateThread({ status: "active" }); + }); + + const steerTurn: ProviderAdapter.ProviderAdapterV2SessionRuntime["steerTurn"] = Effect.fn( + "VibeAdapterV2.steerTurn", + )(function* (steerInput) { + const nativeSessionId = sessionIdOf(); + if (!nativeSessionId || !state.activeTurn) { + return yield* new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver: VIBE_PROVIDER, + providerThreadId: steerInput.providerThread.id, + }); + } + yield* Effect.mapError( + client.turnSteer({ + sessionId: nativeSessionId, + expectedTurnId: state.activeTurn.nativeTurnId, + message: [{ type: "text", text: steerInput.message.text }], + }), + protocolError, + ); + }); + + const interruptTurn: ProviderAdapter.ProviderAdapterV2SessionRuntime["interruptTurn"] = + Effect.fn("VibeAdapterV2.interruptTurn")(function* () { + const nativeSessionId = sessionIdOf(); + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterSessionStoppedError({ + driver: VIBE_PROVIDER, + }); + } + const nativeTurnId = state.activeTurn?.nativeTurnId; + if (nativeTurnId === undefined) return; + yield* Effect.mapError( + client.turnInterrupt({ sessionId: nativeSessionId, expectedTurnId: nativeTurnId }), + protocolError, + ).pipe(Effect.ignore); + }); + + const respondToRuntimeRequest: ProviderAdapter.ProviderAdapterV2SessionRuntime["respondToRuntimeRequest"] = + Effect.fn("VibeAdapterV2.respondToRuntimeRequest")(function* (responseInput) { + const nativeSessionId = sessionIdOf(); + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: VIBE_PROVIDER, + detail: "No Mistral Vibe session owns this runtime request.", + }); + } + const decision = responseInput.decision ?? "cancel"; + const result = + decision === "cancel" + ? { outcome: "cancelled" as const } + : decision === "decline" + ? { outcome: "denied" as const } + : { + outcome: "approved" as const, + ...(responseInput.answers ? { answers: responseInput.answers } : {}), + }; + yield* Effect.mapError( + client.callbackResult({ + sessionId: nativeSessionId, + callbackId: responseInput.requestId, + result, + }), + protocolError, + ); + }); + + const readThreadSnapshot: ProviderAdapter.ProviderAdapterV2SessionRuntime["readThreadSnapshot"] = + Effect.fn("VibeAdapterV2.readThreadSnapshot")(function* (snapshotInput) { + const nativeSessionId = snapshotInput.providerThread.nativeThreadRef?.nativeId; + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: VIBE_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + }); + } + const history = yield* Effect.mapError( + client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500 }), + protocolError, + ); + const turns = yield* Effect.mapError( + client.sessionTurnsList({ sessionId: nativeSessionId, limit: 200 }), + protocolError, + ); + const now = yield* DateTime.now; + const messages: OrchestrationV2ConversationMessage[] = []; + for (const item of history.items) { + const entry = asRecord(item); + const text = entryText(entry); + if (!entry || !text) continue; + const role = str(entry["role"]) === "user" ? "user" : "assistant"; + const nativeItemId = str(entry["id"]) ?? `entry-${messages.length}`; + messages.push({ + createdBy: role === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }), + threadId: input.threadId, + runId: null, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }); + } + const providerTurns: OrchestrationV2ProviderTurn[] = turns.map((turn, index) => ({ + id: idAllocator.derive.providerTurn({ driver: VIBE_PROVIDER, nativeTurnId: turn.id }), + providerThreadId: snapshotInput.providerThread.id, + nodeId: snapshotInput.providerThread.ownerNodeId ?? NodeId.make(`vibe-root-${turn.id}`), + runAttemptId: null, + nativeTurnRef: nativeRef(turn.id), + ordinal: index + 1, + status: (turn.status === "in_progress" + ? "running" + : turn.status) as OrchestrationV2ProviderTurn["status"], + startedAt: utcFromMillis(turn.startedAt), + completedAt: utcFromMillis(turn.completedAt), + })); + return { + providerThread: snapshotInput.providerThread, + providerTurns, + messages, + runtimeRequests: [], + } satisfies ProviderAdapter.ProviderAdapterV2ThreadSnapshot; + }); + + const rollbackThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["rollbackThread"] = + Effect.fn("VibeAdapterV2.rollbackThread")(function* (rollbackInput) { + const nativeSessionId = rollbackInput.providerThread.nativeThreadRef?.nativeId; + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + }); + } + if (rollbackInput.target.type === "provider_turn") { + const entryId = rollbackInput.target.providerTurn.nativeTurnRef?.nativeId; + if (entryId) { + yield* Effect.mapError( + client.request("session/rewind", { sessionId: nativeSessionId, entryId }), + protocolError, + ).pipe(Effect.ignore); + } + } + return yield* readThreadSnapshot({ providerThread: rollbackInput.providerThread }); + }); + + const forkThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["forkThread"] = Effect.fn( + "VibeAdapterV2.forkThread", + )(function* (forkInput) { + const sourceId = forkInput.sourceProviderThread.nativeThreadRef?.nativeId; + if (!sourceId) { + return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + }); + } + const forked = yield* Effect.mapError( + client.request("session/fork", { + sourceSessionId: sourceId, + ...(forkInput.providerTurnId !== undefined + ? { afterTurnId: forkInput.providerTurnId } + : {}), + }), + protocolError, + ); + const nativeThreadId = str( + asRecord(asRecord(asRecord(forked)?.["state"])?.["session"])?.["id"], + ); + if (!nativeThreadId) { + return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + }); + } + const createdAt = yield* DateTime.now; + return { + id: idAllocator.derive.providerThread({ + driver: VIBE_PROVIDER, + providerInstanceId: options.instanceId, + nativeThreadId, + }), + driver: VIBE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId, + appThreadId: forkInput.targetThreadId, + ownerNodeId: forkInput.ownerNodeId ?? null, + nativeThreadRef: nativeRef(nativeThreadId), + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: { providerThreadId: forkInput.sourceProviderThread.id }, + createdAt, + updatedAt: createdAt, + } satisfies OrchestrationV2ProviderThread; + }); + + return { + instanceId: options.instanceId, + driver: VIBE_PROVIDER, + providerSessionId, + providerSession: state.session, + events: Stream.fromPubSub(events), + ensureThread, + resumeThread: (resumeInput) => + ensureThread({ + threadId: resumeInput.threadId ?? input.threadId, + modelSelection: resumeInput.modelSelection ?? input.modelSelection, + runtimePolicy: resumeInput.runtimePolicy ?? input.runtimePolicy, + providerSessionId, + existingProviderThread: resumeInput.providerThread, + }), + startTurn, + steerTurn, + interruptTurn, + respondToRuntimeRequest, + readThreadSnapshot, + rollbackThread, + forkThread, + } satisfies ProviderAdapter.ProviderAdapterV2SessionRuntime; + }); + + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: VIBE_PROVIDER, + getCapabilities: Effect.fn("VibeAdapterV2.getCapabilities")(function* () { + return VibeProviderCapabilitiesV2; + }), + planSelectionTransition: Effect.fn("VibeAdapterV2.planSelectionTransition")(function* () { + return turnScopedSelectionTransition(); + }), + openSession: Effect.fn("VibeAdapterV2.openSession")(function* (input) { + return yield* Effect.provideService( + openSessionInner(input), + ChildProcessSpawner.ChildProcessSpawner, + spawner, + ); + }), + }); +}); + +export type VibeAdapterV2Env = + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | IdAllocator.IdAllocatorV2 + | ProviderHost.ProviderHost; diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts new file mode 100644 index 0000000000000..453c4092fd4f6 --- /dev/null +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as Schema from "effect/Schema"; + +import { VibeSettings } from "../settings.ts"; +import { + DEFAULT_VIBE_MODEL, + VIBE_DEFAULT_BINARY, + buildVibeEnvironment, + buildVibeSpawnInput, + resolveVibeModelId, + vibeAgentForRuntimeMode, + vibeWorktreeInputForCwd, +} from "./appServerSupport.ts"; + +const decodeSettings = Schema.decodeSync(VibeSettings); + +describe("buildVibeSpawnInput", () => { + it("defaults to the vibe-app-server binary in the given checkout cwd", () => { + const spawn = buildVibeSpawnInput(undefined, "/repo/.t3/worktrees/thread-1", { PATH: "/bin" }); + expect(spawn.command).toBe(VIBE_DEFAULT_BINARY); + expect(spawn.args).toEqual([]); + expect(spawn.cwd).toBe("/repo/.t3/worktrees/thread-1"); + expect(spawn.env?.PATH).toBe("/bin"); + }); + + it("honours a configured binary path", () => { + const spawn = buildVibeSpawnInput( + { ...decodeSettings({}), binaryPath: "/opt/vibe/bin/vibe-app-server" }, + "/repo", + undefined, + ); + expect(spawn.command).toBe("/opt/vibe/bin/vibe-app-server"); + }); +}); + +describe("buildVibeEnvironment", () => { + it("passes the environment through when the local model is off", () => { + const env = buildVibeEnvironment(decodeSettings({}), { HOME: "/home/u" }); + expect(env).toEqual({ HOME: "/home/u" }); + }); + + it("injects VIBE_PROVIDERS/VIBE_MODELS for a local OpenAI-compatible endpoint", () => { + const env = buildVibeEnvironment( + decodeSettings({ + localModelEnabled: true, + localModelBaseUrl: "http://localhost:8000/v1", + localModelId: "devstral", + }), + { HOME: "/home/u" }, + ); + const providers = JSON.parse(env?.VIBE_PROVIDERS ?? "[]") as Array>; + const models = JSON.parse(env?.VIBE_MODELS ?? "[]") as Array>; + expect(providers[0]?.api_base).toBe("http://localhost:8000/v1"); + expect(providers[0]?.api_style).toBe("openai"); + expect(models[0]?.name).toBe("devstral"); + expect(models[0]?.provider).toBe("local"); + expect(env?.HOME).toBe("/home/u"); + }); +}); + +describe("vibeAgentForRuntimeMode", () => { + it("maps T3 runtime modes onto Vibe agents", () => { + expect(vibeAgentForRuntimeMode("approval-required")).toEqual({ + agent: "default", + autoApprove: false, + }); + expect(vibeAgentForRuntimeMode("auto-accept-edits")).toEqual({ + agent: "accept-edits", + autoApprove: false, + }); + expect(vibeAgentForRuntimeMode("auto")).toEqual({ agent: "accept-edits", autoApprove: false }); + expect(vibeAgentForRuntimeMode("full-access")).toEqual({ + agent: "accept-edits", + autoApprove: true, + }); + expect(vibeAgentForRuntimeMode("approval-required", "plan")).toEqual({ + agent: "plan", + autoApprove: false, + }); + }); +}); + +describe("resolveVibeModelId", () => { + it("falls back to the default model slug", () => { + expect(resolveVibeModelId(undefined)).toBe(DEFAULT_VIBE_MODEL); + expect(resolveVibeModelId(" ")).toBe(DEFAULT_VIBE_MODEL); + expect(resolveVibeModelId("devstral-small")).toBe("devstral-small"); + }); +}); + +describe("vibeWorktreeInputForCwd", () => { + it("adopts the thread's existing checkout as the session worktree", () => { + expect(vibeWorktreeInputForCwd("/repo/.t3/worktrees/thread-1")).toEqual({ + kind: "existing", + cwd: "/repo/.t3/worktrees/thread-1", + }); + }); +}); diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts new file mode 100644 index 0000000000000..db589554113e3 --- /dev/null +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -0,0 +1,180 @@ +import { + type ProviderInteractionMode, + ProviderDriverKind, + type RuntimeMode, +} from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as Scope from "effect/Scope"; + +import * as VibeAppServer from "@t3tools/vibe-app-server/client"; +import * as VibeError from "@t3tools/vibe-app-server/errors"; + +export const VIBE_PROVIDER = ProviderDriverKind.make("vibe"); +export const VIBE_DEFAULT_BINARY = "vibe-app-server"; +export const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; + +/** Mistral Vibe's application rate-limit code, outside the JSON-RPC reserved range. */ +export const MISTRAL_VIBE_RATE_LIMITED = -31001; + +type VibeAppServerSettings = Pick< + VibeSettings, + "binaryPath" | "localModelEnabled" | "localModelBaseUrl" | "localModelId" +>; + +export interface VibeSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; +} + +export function buildVibeEnvironment( + settings: VibeAppServerSettings | null | undefined, + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv | undefined { + if (!settings?.localModelEnabled) return environment; + const apiBase = settings.localModelBaseUrl.trim() || "http://127.0.0.1:11434/v1"; + const model = settings.localModelId.trim() || "devstral-small-2"; + return { + ...environment, + VIBE_PROVIDERS: JSON.stringify([ + { + name: "local", + api_base: apiBase, + api_style: "openai", + backend: "generic", + api_key_env_var: "", + }, + ]), + VIBE_MODELS: JSON.stringify([ + { + name: model, + provider: "local", + alias: "local", + input_price: 0, + output_price: 0, + }, + ]), + }; +} + +/** The app server runs in the session's cwd — the thread's checkout when it owns a worktree. */ +export function buildVibeSpawnInput( + settings: VibeAppServerSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): VibeSpawnInput { + const env = buildVibeEnvironment(settings, environment); + return { + command: settings?.binaryPath || VIBE_DEFAULT_BINARY, + args: [], + cwd, + ...(env ? { env } : {}), + }; +} + +export function resolveVibeModelId(model: string | null | undefined): string { + return model?.trim() || DEFAULT_VIBE_MODEL; +} + +/** + * Maps T3's runtime policy to Vibe's agent (permission mode). `full-access` + * additionally sets `autoApprove`, which bypasses tool permissions entirely. + */ +export function vibeAgentForRuntimeMode( + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode = "default", +): { readonly agent: string; readonly autoApprove: boolean } { + if (interactionMode === "plan") return { agent: "plan", autoApprove: false }; + switch (runtimeMode) { + case "approval-required": + return { agent: "default", autoApprove: false }; + case "auto-accept-edits": + case "auto": + return { agent: "accept-edits", autoApprove: false }; + case "full-access": + return { agent: "accept-edits", autoApprove: true }; + } +} + +/** Native worktree input: adopt the checkout the thread already owns. */ +export function vibeWorktreeInputForCwd(cwd: string): { + readonly kind: "existing"; + readonly cwd: string; +} { + return { kind: "existing", cwd }; +} + +export interface VibeAppServerHandle { + readonly client: VibeAppServer.VibeAppServerClient["Service"]; + readonly child: ChildProcessSpawner.ChildProcessHandle; +} + +/** + * Spawns `vibe-app-server` in `cwd` and returns a connected client. + * Scoped: closing the scope kills the process. + */ +export const withVibeAppServerClient = Effect.fn("VibeAppServerSupport.withVibeAppServerClient")( + function* ( + input: { + readonly settings?: VibeAppServerSettings | null | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv | undefined; + readonly clientInfo?: { readonly name: string; readonly version: string }; + readonly logProtocol?: boolean; + }, + use: ( + client: VibeAppServer.VibeAppServerClient["Service"], + serverInfo: VibeAppServer.InitializeResult["serverInfo"], + ) => Effect.Effect, + ): Effect.fn.Return< + A, + E | VibeError.VibeAppServerError, + R | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + > { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawn = buildVibeSpawnInput(input.settings, input.cwd, input.environment); + const child = yield* spawner + .spawn( + ChildProcess.make(spawn.command, [...spawn.args], { + ...(spawn.cwd !== undefined ? { cwd: spawn.cwd } : {}), + ...(spawn.env !== undefined ? { env: spawn.env } : {}), + }), + ) + .pipe( + Effect.mapError( + (cause): VibeError.VibeAppServerError => + new VibeError.VibeAppServerProtocolError({ + detail: `Failed to spawn ${spawn.command}.`, + cause, + }), + ), + ); + const client = Context.get( + yield* Layer.build( + VibeAppServer.layerChildProcess( + child, + input.logProtocol === true ? { logIncoming: true, logOutgoing: true } : {}, + ), + ), + VibeAppServer.VibeAppServerClient, + ); + const init = yield* client.initialize({ + clientName: input.clientInfo?.name ?? "t3-code", + clientVersion: input.clientInfo?.version ?? "0.0.0", + capabilities: { + callbackKinds: ["approval", "user_input"], + clientTools: ["filesystem/read", "filesystem/write", "terminal"], + }, + }); + yield* client.initialized(); + return yield* use(client, init.serverInfo).pipe( + Effect.ensuring(child.kill().pipe(Effect.ignore)), + ); + }, +); diff --git a/packages/provider-vibe/src/server/driver.ts b/packages/provider-vibe/src/server/driver.ts new file mode 100644 index 0000000000000..06bd5aa1435a7 --- /dev/null +++ b/packages/provider-vibe/src/server/driver.ts @@ -0,0 +1,164 @@ +import { VibeSettings } from "../settings.ts"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { makeVibeTextGeneration } from "./textGeneration.ts"; +import { makeVibeAdapterV2, type VibeAdapterV2Env } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { buildInitialVibeProviderSnapshot, checkVibeProviderStatus } from "./status.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + resolveProviderMaintenanceCapabilitiesEffect, + type ProviderMaintenanceCapabilitiesResolver, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import { VIBE_PROVIDER } from "./appServerSupport.ts"; + +const decodeVibeSettings = Schema.decodeSync(VibeSettings); +const DRIVER_KIND = VIBE_PROVIDER; +const VIBE_NPM_PACKAGE = "mistral-vibe"; +// Vibe is a uv/pip tool; updates stay manual from T3 Code. +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: VIBE_NPM_PACKAGE, + }), + ), +}; + +export type VibeDriverEnv = + | VibeAdapterV2Env + | ChildProcessSpawner.ChildProcessSpawner + | SecureRandom.SecureRandom + | FileSystem.FileSystem + | ProviderLatestVersions.ProviderLatestVersions + | Path.Path + | ProviderHost.ProviderHost; + +export const VibeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Mistral Vibe", + supportsMultipleInstances: true, + }, + configSchema: VibeSettings, + defaultConfig: (): VibeSettings => decodeVibeSettings({}), + create: Effect.fn("VibeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const random = yield* SecureRandom.SecureRandom; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const host = yield* ProviderHost.ProviderHost; + const { cwd } = host.paths; + const processEnv = yield* mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies VibeSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const orchestrationAdapter = yield* makeVibeAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(SecureRandom.SecureRandom, random), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Mistral Vibe orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeVibeTextGeneration(effectiveConfig, processEnv).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const checkProvider = checkVibeProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialVibeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: () => Effect.void, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Mistral Vibe snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-vibe/src/server/status.test.ts b/packages/provider-vibe/src/server/status.test.ts new file mode 100644 index 0000000000000..86d9466af25a6 --- /dev/null +++ b/packages/provider-vibe/src/server/status.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { ConfigView } from "@t3tools/vibe-app-server/client"; + +import { buildVibeCapabilities, vibeModelsFromConfig } from "./status.ts"; +import { vibeTurnFailure } from "./adapter.ts"; + +describe("vibeModelsFromConfig", () => { + it("maps config models to provider models with the default flagged", () => { + const config = { + defaultModelAlias: "mistral-large-4-0-3", + models: [ + { + name: "mistral-large-4-0", + alias: "mistral-large-4-0-3", + displayName: "Mistral Large 4", + thinkingLevels: ["low", "medium", "high"], + thinking: "high", + }, + { name: "devstral-small", alias: "devstral-small-2", displayName: "Devstral Small" }, + ], + } as unknown as ConfigView; + const models = vibeModelsFromConfig(config); + expect(models.map((m) => m.slug)).toEqual(["mistral-large-4-0-3", "devstral-small-2"]); + expect(models[0]?.isDefault).toBe(true); + expect(models[0]?.name).toBe("Mistral Large 4"); + expect(models[1]?.isDefault).toBeUndefined(); + }); + + it("falls back when the server lists no models", () => { + const models = vibeModelsFromConfig({ models: [] } as unknown as ConfigView); + expect(models.length).toBeGreaterThan(0); + expect(models.some((m) => m.isDefault === true)).toBe(true); + }); +}); + +describe("buildVibeCapabilities", () => { + it("exposes thinking levels as a select option", () => { + const capabilities = buildVibeCapabilities({ + thinkingLevels: ["low", "high"], + thinking: "high", + }); + const descriptor = capabilities.optionDescriptors?.[0]; + expect(descriptor?.type).toBe("select"); + expect( + descriptor && "options" in descriptor ? descriptor.options.map((o) => o.id) : [], + ).toEqual(["low", "high"]); + }); + + it("returns empty capabilities without thinking levels", () => { + expect(buildVibeCapabilities({}).optionDescriptors).toEqual([]); + }); +}); + +describe("vibeTurnFailure", () => { + it("classifies Vibe's -31001 rate-limit code as usage_limit", () => { + const failure = vibeTurnFailure({ + status: "failed", + error: { code: -31001, message: "rate limited" }, + }); + expect(failure?.class).toBe("usage_limit"); + expect(failure?.code).toBe("-31001"); + }); + + it("classifies other errors as provider_error", () => { + const failure = vibeTurnFailure({ status: "failed", error: { code: -32603, message: "boom" } }); + expect(failure?.class).toBe("provider_error"); + }); +}); diff --git a/packages/provider-vibe/src/server/status.ts b/packages/provider-vibe/src/server/status.ts new file mode 100644 index 0000000000000..a6c700abb128c --- /dev/null +++ b/packages/provider-vibe/src/server/status.ts @@ -0,0 +1,264 @@ +import { + type ModelCapabilities, + type ServerProviderAuth, + type ServerProviderModel, + type ServerProviderUsageLimits, +} from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import type { AccountView, ConfigView } from "@t3tools/vibe-app-server/client"; +import { DEFAULT_VIBE_MODEL, withVibeAppServerClient } from "./appServerSupport.ts"; +import { accountViewToUsageLimits } from "./usageLimits.ts"; + +const VIBE_PRESENTATION = { + displayName: "Mistral Vibe", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"] as const, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: DEFAULT_VIBE_MODEL, + name: "Mistral Medium 3.5", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +const PROBE_TIMEOUT_MS = 30_000; + +function authFromAccount(account: AccountView): ServerProviderAuth { + switch (account.status) { + case "ready": + return { + status: "authenticated", + type: "vibe", + label: account.plan?.title ?? account.plan?.name ?? "Mistral Vibe", + }; + case "missing_key": + case "unauthorized": + return { status: "unauthenticated", type: "vibe", label: "Mistral Vibe" }; + default: + return { status: "unknown" }; + } +} + +/** Thinking levels advertised by a model become a select option descriptor, like Grok's effort picker. */ +export function buildVibeCapabilities(model: { + thinkingLevels?: ReadonlyArray | undefined; + thinking?: string | undefined; +}): ModelCapabilities { + const levels = (model.thinkingLevels ?? []).filter((level) => level.trim().length > 0); + if (levels.length === 0) return EMPTY_CAPABILITIES; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "thinking", + label: "Thinking", + options: levels.map((level) => ({ + value: level, + label: level, + ...(model.thinking === level ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +export function vibeModelsFromConfig( + config: ConfigView, + fallback: ReadonlyArray = FALLBACK_MODELS, +): ReadonlyArray { + const entries = config.models ?? []; + if (entries.length === 0) return [...fallback]; + const seen = new Set(); + const models: ServerProviderModel[] = []; + for (const entry of entries) { + const slug = entry.alias?.trim() || entry.name.trim(); + if (!slug || seen.has(slug)) continue; + seen.add(slug); + models.push({ + slug, + name: entry.displayName?.trim() || entry.name.trim() || slug, + isCustom: false, + ...(config.defaultModelAlias !== undefined && config.defaultModelAlias === slug + ? { isDefault: true } + : {}), + capabilities: buildVibeCapabilities(entry), + }); + } + return models.length > 0 ? models : [...fallback]; +} + +export const buildInitialVibeProviderSnapshot = Effect.fn( + "VibeStatus.buildInitialVibeProviderSnapshot", +)(function* (settings: VibeSettings) { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = providerModelsFromSettings( + FALLBACK_MODELS, + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + 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.", + }, + }); +}); + +export interface VibeProbeResult { + readonly version: string | null; + readonly auth: ServerProviderAuth; + readonly usageLimits: ServerProviderUsageLimits | undefined; + readonly models: ReadonlyArray; + readonly defaultAgent: string | undefined; + readonly installed: boolean; + readonly message?: string; +} + +/** + * Probes a live `vibe-app-server`: starts an ephemeral session in `probeCwd`, + * then reads the account (auth + budget limits) and the config (models). + */ +const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv, + probeCwd: string, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.scoped( + withVibeAppServerClient( + { + settings, + cwd: probeCwd, + environment, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }, + (client, serverInfo) => + Effect.gen(function* () { + const version = serverInfo.version; + const started = yield* client.sessionStart({ + kind: "ephemeral", + agentConfig: { + cwd: probeCwd, + headless: true, + trustWorkspace: settings.trustWorkspace, + }, + }); + const sessionId = started.state.session.id; + const account = yield* client.accountRead(sessionId); + const config = yield* client + .configRead(sessionId) + .pipe(Effect.orElseSucceed(() => ({}) as ConfigView)); + yield* client.sessionDelete(sessionId).pipe(Effect.ignore); + return { + version, + auth: authFromAccount(account), + usageLimits: accountViewToUsageLimits(account, checkedAt), + models: vibeModelsFromConfig(config), + defaultAgent: config.defaultAgent ?? undefined, + installed: true, + } satisfies VibeProbeResult; + }).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.map((option) => + option._tag === "None" + ? ({ + version: null, + auth: { status: "unknown" }, + usageLimits: undefined, + models: [...FALLBACK_MODELS], + defaultAgent: undefined, + installed: true, + message: "Mistral Vibe app server timed out while probing.", + } satisfies VibeProbeResult) + : option.value, + ), + ), + ), + ).pipe( + Effect.catchCause((cause) => + Effect.succeed({ + version: null, + auth: { status: "unknown" }, + usageLimits: undefined, + models: [...FALLBACK_MODELS], + defaultAgent: undefined, + installed: false, + message: `Mistral Vibe app server probe failed: ${cause.toString().slice(0, 300)}`, + } satisfies VibeProbeResult), + ), + ); +}); + +export const checkVibeProviderStatus = Effect.fn("VibeStatus.checkVibeProviderStatus")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv = process.env, + probeCwd: string = process.cwd(), +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = providerModelsFromSettings( + FALLBACK_MODELS, + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); + if (!settings.enabled) { + return yield* buildInitialVibeProviderSnapshot(settings); + } + const probe = yield* probeVibeAppServer(settings, environment, probeCwd); + const models = + probe.models.length > 0 + ? providerModelsFromSettings(probe.models, settings.customModels ?? [], EMPTY_CAPABILITIES) + : fallbackModels; + const status = !probe.installed + ? "error" + : probe.auth.status === "authenticated" + ? "ready" + : "warning"; + return buildServerProvider({ + presentation: VIBE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: probe.installed, + version: probe.version, + status, + auth: probe.auth, + ...(probe.message ? { message: probe.message } : {}), + ...(probe.usageLimits ? { usageLimits: probe.usageLimits } : {}), + }, + }); +}); diff --git a/packages/provider-vibe/src/server/textGeneration.ts b/packages/provider-vibe/src/server/textGeneration.ts new file mode 100644 index 0000000000000..43cd9643a5c46 --- /dev/null +++ b/packages/provider-vibe/src/server/textGeneration.ts @@ -0,0 +1,136 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import type * as VibeError from "@t3tools/vibe-app-server/errors"; + +import { TextGenerationError } from "@t3tools/contracts"; +import type { VibeSettings } from "../settings.ts"; + +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import type { VibeAppServerClient } from "@t3tools/vibe-app-server/client"; +import { resolveVibeModelId, withVibeAppServerClient } from "./appServerSupport.ts"; + +const VIBE_TEXT_GEN_TIMEOUT_MS = 180_000; +const TERMINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted", "cancelled"]); + +const isTextGenerationError = Schema.is(TextGenerationError); + +interface HistoryEntryLike { + readonly role?: unknown; + readonly type?: unknown; + readonly content?: unknown; +} + +function entryText(entry: HistoryEntryLike): string { + if (entry.type !== "message" || !Array.isArray(entry.content)) return ""; + return entry.content + .map((block) => + typeof block === "object" && block !== null && (block as { type?: unknown }).type === "text" + ? String((block as { text?: unknown }).text ?? "") + : "", + ) + .join("") + .trim(); +} + +const waitForTurnTerminal = Effect.fn("VibeTextGeneration.waitForTurnTerminal")(function* ( + client: VibeAppServerClient["Service"], + sessionId: string, +) { + while (true) { + const turns = yield* client.sessionTurnsList({ sessionId, limit: 20 }); + const active = turns.find((turn) => !TERMINAL_TURN_STATUSES.has(turn.status)); + if (active === undefined) return; + yield* Effect.sleep("500 millis"); + } +}); + +export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(function* ( + settings: VibeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runVibeJson: TextGenerationOperations.Runner = Effect.fn("VibeTextGeneration.runVibeJson")( + function* (request) { + const { operation, cwd, prompt, modelSelection } = request; + const output = yield* withVibeAppServerClient( + { settings, cwd, environment, clientInfo: { name: "t3-code-vibe-text", version: "0.0.0" } }, + (client) => + Effect.gen(function* () { + const started = yield* client.sessionStart({ + kind: "ephemeral", + agentConfig: { + cwd, + headless: true, + trustWorkspace: settings.trustWorkspace, + }, + ...(resolveVibeModelId(modelSelection.model) !== "" + ? { model: resolveVibeModelId(modelSelection.model) } + : {}), + sessionOnly: true, + }); + const sessionId = started.state.session.id; + yield* client.sessionTurnEnqueue({ + sessionId, + entries: [{ role: "user", content: [{ type: "text", text: prompt }] }], + }); + yield* waitForTurnTerminal(client, sessionId); + const history = yield* client.sessionHistoryList({ sessionId, limit: 100 }); + const reply = [...history.items] + .reverse() + .map((item) => entryText(item as HistoryEntryLike)) + .find((text) => text.length > 0); + yield* client.sessionDelete(sessionId).pipe(Effect.ignore); + return reply ?? ""; + }), + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.timeoutOption(VIBE_TEXT_GEN_TIMEOUT_MS), + Effect.flatMap((option) => + option._tag === "None" + ? Effect.fail( + new TextGenerationError({ operation, detail: "Mistral Vibe request timed out." }), + ) + : Effect.succeed(option.value), + ), + Effect.mapError((cause: VibeError.VibeAppServerError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Mistral Vibe app server request failed.", + cause, + }), + ), + ); + + if (!output) { + return yield* new TextGenerationError({ + operation, + detail: "Mistral Vibe returned empty output.", + }); + } + return yield* TextGenerationOperations.decodeJsonReply(request, "Mistral Vibe", output); + }, + (effect, request) => { + const { operation } = request; + return effect.pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Mistral Vibe text generation failed.", + cause, + }), + ), + ); + }, + ); + + return TextGenerationOperations.fromRunner( + "VibeTextGeneration", + TextGenerationOperations.withoutInvestigation("Mistral Vibe", runVibeJson), + ); +}); diff --git a/packages/provider-vibe/src/server/usageLimits.test.ts b/packages/provider-vibe/src/server/usageLimits.test.ts new file mode 100644 index 0000000000000..6a8019aaa37e9 --- /dev/null +++ b/packages/provider-vibe/src/server/usageLimits.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { AccountView } from "@t3tools/vibe-app-server/client"; + +import { accountViewToUsageLimits, MISTRAL_USAGE_URL } from "./usageLimits.ts"; + +const account = (budget: AccountView["budget"]): AccountView => + ({ + status: "ready", + plan: { kind: "chat", name: "PRO", title: "Pro" }, + budget, + }) as AccountView; + +describe("accountViewToUsageLimits", () => { + it("maps the monthly Vibe Code budget to a monthly window", () => { + const limits = accountViewToUsageLimits( + account({ usagePercentage: 42.5, resetAt: "2026-12-01T00:00:00Z", paygEnabled: false }), + "2026-11-15T00:00:00.000Z", + ); + expect(limits.unavailable).toBeUndefined(); + expect(limits.windows).toHaveLength(1); + expect(limits.windows[0]).toMatchObject({ + id: "vibe-code-monthly", + kind: "monthly", + usedPercent: 42.5, + resetsAt: "2026-12-01T00:00:00Z", + }); + }); + + it("clamps out-of-range percentages", () => { + const limits = accountViewToUsageLimits( + account({ usagePercentage: 250, resetAt: null, paygEnabled: true }), + "2026-11-15T00:00:00.000Z", + ); + expect(limits.windows[0]?.usedPercent).toBe(100); + expect(limits.windows[0]?.label).toContain("pay-as-you-go"); + }); + + it("reports unsupported with a console link when no budget is exposed", () => { + const limits = accountViewToUsageLimits(account(null), "2026-11-15T00:00:00.000Z"); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.externalUsage?.url).toBe(MISTRAL_USAGE_URL); + expect(limits.windows).toHaveLength(0); + }); +}); diff --git a/packages/provider-vibe/src/server/usageLimits.ts b/packages/provider-vibe/src/server/usageLimits.ts new file mode 100644 index 0000000000000..a25f3bdd45cfd --- /dev/null +++ b/packages/provider-vibe/src/server/usageLimits.ts @@ -0,0 +1,40 @@ +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { AccountView } from "@t3tools/vibe-app-server/client"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; + +export const MISTRAL_USAGE_URL = "https://console.mistral.ai/usage"; + +/** + * Maps the app server's `account/read` billing view to the native usage-limits + * contract: the monthly Vibe Code allowance becomes a "monthly" window with a + * reset timestamp. Accounts without a budget (API keys, self-hosted) report + * unsupported plus a console link, like Codex's managed account. + */ +export function accountViewToUsageLimits( + account: AccountView, + checkedAt: string, +): ServerProviderUsageLimits { + const budget = account.budget; + if (budget === null || budget === undefined || !Number.isFinite(budget.usagePercentage)) { + return { + ...makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "This Vibe account does not expose a monthly allowance.", + }), + externalUsage: { label: "Mistral usage", url: MISTRAL_USAGE_URL }, + }; + } + const window: ServerProviderUsageWindow = { + id: "vibe-code-monthly", + kind: "monthly", + label: budget.paygEnabled === true ? "Monthly allowance (pay-as-you-go)" : "Monthly allowance", + usedPercent: clampPercent(budget.usagePercentage), + ...(budget.resetAt ? { resetsAt: budget.resetAt } : {}), + }; + return makeUsageLimits({ checkedAt, windows: [window] }); +} diff --git a/packages/provider-vibe/src/settings.ts b/packages/provider-vibe/src/settings.ts new file mode 100644 index 0000000000000..afeb4c8a51e31 --- /dev/null +++ b/packages/provider-vibe/src/settings.ts @@ -0,0 +1,80 @@ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const VibeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("vibe-app-server").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Mistral Vibe app server binary.", + providerSettingsForm: { placeholder: "vibe-app-server", clearWhenEmpty: "omit" }, + }), + ), + trustWorkspace: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Trust workspaces", + description: + "Automatically trust projects opened in T3 Code so Vibe can load repository instructions such as AGENTS.md. Trust is persisted by Vibe for each workspace.", + providerSettingsForm: { control: "switch" }, + }), + ), + localModelEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Use a local model", + description: + "Point Vibe's local model option at an OpenAI-compatible server such as vLLM, Ollama, LM Studio, or llama.cpp.", + providerSettingsForm: { control: "switch" }, + }), + ), + localModelBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://127.0.0.1:11434/v1")), + Schema.annotateKey({ + title: "Local API URL", + description: + "OpenAI-compatible endpoint. vLLM commonly uses port 8000; Ollama normally uses port 11434.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:11434/v1", + clearWhenEmpty: "omit", + }, + }), + ), + localModelId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("devstral-small-2")), + Schema.annotateKey({ + title: "Local model ID", + description: + "Model ID exposed by your server, for example mistralai/Devstral-Small-2-24B-Instruct-2512 or an Ollama model name.", + providerSettingsForm: { + placeholder: "devstral-small-2", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: [ + "binaryPath", + "trustWorkspace", + "localModelEnabled", + "localModelBaseUrl", + "localModelId", + ], + }, +); +export type VibeSettings = typeof VibeSettings.Type; diff --git a/packages/provider-vibe/tsconfig.json b/packages/provider-vibe/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-vibe/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/vibe-app-server/package.json b/packages/vibe-app-server/package.json new file mode 100644 index 0000000000000..61abdd6897473 --- /dev/null +++ b/packages/vibe-app-server/package.json @@ -0,0 +1,32 @@ +{ + "name": "@t3tools/vibe-app-server", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./protocol": { + "types": "./src/protocol.ts", + "import": "./src/protocol.ts" + }, + "./errors": { + "types": "./src/errors.ts", + "import": "./src/errors.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/vibe-app-server/src/client.test.ts b/packages/vibe-app-server/src/client.test.ts new file mode 100644 index 0000000000000..6fdb7d703d9c0 --- /dev/null +++ b/packages/vibe-app-server/src/client.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; + +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as VibeClient from "./client.ts"; +import * as VibeError from "./errors.ts"; +import * as VibeProtocol from "./protocol.ts"; + +const isRequestError = Schema.is(VibeError.VibeAppServerRequestError); + +describe("parseJsonRpcLine", () => { + it("parses requests, notifications, successes and failures", () => { + const parse = VibeProtocol.parseJsonRpcLine; + expect( + VibeProtocol.isJsonRpcRequest(parse('{"jsonrpc":"2.0","id":1,"method":"session/start"}')!), + ).toBe(true); + expect( + VibeProtocol.isJsonRpcNotification(parse('{"jsonrpc":"2.0","method":"turn/started"}')!), + ).toBe(true); + expect( + VibeProtocol.isJsonRpcSuccess(parse('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}')!), + ).toBe(true); + expect( + VibeProtocol.isJsonRpcFailure( + parse('{"jsonrpc":"2.0","id":1,"error":{"code":"conflict","message":"x"}}')!, + ), + ).toBe(true); + expect(parse("not json")).toBeUndefined(); + expect(parse("")).toBeUndefined(); + }); +}); + +const encoder = new TextEncoder(); +const serverLine = (input: Queue.Queue, payload: unknown) => + Queue.offer(input, encoder.encode(`${JSON.stringify(payload)}\n`)); + +/** Fake vibe-app-server: answers initialize/account/read, fails session/stop, notifies on anything else. */ +const fakeServer = ( + output: Queue.Queue, + input: Queue.Queue, +): Effect.Effect => + Effect.forever( + Effect.gen(function* () { + const line = yield* Queue.take(output); + const message = VibeProtocol.parseJsonRpcLine(line); + if (!message || !VibeProtocol.isJsonRpcRequest(message)) return; + switch (message.method) { + case "initialize": + return yield* serverLine(input, { + jsonrpc: "2.0", + id: message.id, + result: { serverInfo: { name: "vibe-app-server", version: "9.9.9" }, processId: 42 }, + }); + case "account/read": + return yield* serverLine(input, { + jsonrpc: "2.0", + id: message.id, + result: { + account: { + status: "ready", + plan: { kind: "chat", name: "PRO", title: "Pro" }, + budget: { + usagePercentage: 12.5, + resetAt: "2026-12-01T00:00:00Z", + paygEnabled: false, + }, + }, + }, + }); + case "session/stop": + return yield* serverLine(input, { + jsonrpc: "2.0", + id: message.id, + error: { code: "conflict", message: "no session" }, + }); + default: + yield* serverLine(input, { + jsonrpc: "2.0", + method: "turn/started", + params: { eventId: 1, sessionId: "s1" }, + }); + return yield* serverLine(input, { jsonrpc: "2.0", id: message.id, result: {} }); + } + }), + ); + +describe("VibeAppServerClient over in-memory stdio", () => { + it.effect("round-trips typed requests, dispatches notifications, maps errors", () => + Effect.scoped( + Effect.gen(function* () { + const { stdio, input, output } = yield* VibeClient.makeInMemoryStdio(); + const serverFiber = yield* Effect.forkScoped(fakeServer(output, input)); + const client = yield* VibeClient.make(stdio); + + const notifications: Array<{ method: string; params: unknown }> = []; + yield* client.handleUnknownServerNotification((method, params) => + Effect.sync(() => { + notifications.push({ method, params }); + }), + ); + + const init = yield* client.initialize({ clientName: "test", clientVersion: "0.0.0" }); + expect(init.serverInfo.version).toBe("9.9.9"); + yield* client.initialized(); + + const account = yield* client.accountRead("s1"); + expect(account.status).toBe("ready"); + expect(account.budget?.usagePercentage).toBe(12.5); + + // JSON-RPC error surfaces as a typed request error with method attached. + const outcome = yield* Effect.result(client.sessionStop({ sessionId: "s1" })); + const requestError = + Result.isFailure(outcome) && isRequestError(outcome.failure) + ? outcome.failure + : undefined; + expect(requestError?._tag).toBe("VibeAppServerRequestError"); + expect(requestError?.method).toBe("session/stop"); + expect(requestError?.code).toBe("conflict"); + + // Unknown method triggers the notification path on the fake server. + yield* client.sessionDelete("s1"); + yield* Effect.yieldNow; + expect(notifications.some((n) => n.method === "turn/started")).toBe(true); + + yield* Fiber.interrupt(serverFiber); + }), + ), + ); +}); diff --git a/packages/vibe-app-server/src/client.ts b/packages/vibe-app-server/src/client.ts new file mode 100644 index 0000000000000..833fca221a1f1 --- /dev/null +++ b/packages/vibe-app-server/src/client.ts @@ -0,0 +1,621 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as VibeError from "./errors.ts"; +import * as VibeProtocol from "./protocol.ts"; + +const ServerInfo = Schema.Struct({ + name: Schema.String, + version: Schema.String, +}); +export const InitializeResult = Schema.Struct({ + serverInfo: ServerInfo, + processId: Schema.optional(Schema.NullOr(Schema.Number)), +}); +export type InitializeResult = typeof InitializeResult.Type; + +const TokenUsage = Schema.Struct({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + cachedTokens: Schema.optional(Schema.Number), +}); + +const PublicSessionWorktree = Schema.Struct({ + name: Schema.String, + branch: Schema.String, + path: Schema.String, +}); + +export const PublicSession = Schema.Struct({ + id: Schema.String, + rootSessionId: Schema.optional(Schema.NullOr(Schema.String)), + parentSessionId: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(Schema.NullOr(Schema.String)), + preview: Schema.optional(Schema.String), + status: Schema.optional(Schema.Unknown), + createdAt: Schema.optional(Schema.Number), + updatedAt: Schema.optional(Schema.Number), + cwd: Schema.optional(Schema.NullOr(Schema.String)), + workspaceRoots: Schema.optional(Schema.Array(Schema.String)), + worktree: Schema.optional(Schema.NullOr(PublicSessionWorktree)), + model: Schema.optional(Schema.NullOr(Schema.String)), + reasoningEffort: Schema.optional(Schema.NullOr(Schema.String)), + tokenUsage: Schema.optional(Schema.NullOr(TokenUsage)), + contextUsage: Schema.optional(Schema.NullOr(TokenUsage)), +}); +export type PublicSession = typeof PublicSession.Type; + +export const PublicSessionState = Schema.Struct({ + format: Schema.optional(Schema.String), + eventId: Schema.optional(Schema.Number), + session: PublicSession, + isQuiescent: Schema.optional(Schema.NullOr(Schema.Boolean)), + turns: Schema.optional(Schema.Array(Schema.Unknown)), + activeCallbacks: Schema.optional(Schema.Array(Schema.Unknown)), + childSessions: Schema.optional(Schema.Array(Schema.Unknown)), + backgroundProcesses: Schema.optional(Schema.Array(Schema.Unknown)), + retrying: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +export type PublicSessionState = typeof PublicSessionState.Type; + +const EventWatermark = Schema.Struct({ + lastEventId: Schema.optional(Schema.Number), +}); + +export const SessionStartResult = Schema.Struct({ + ...EventWatermark.fields, + state: PublicSessionState, +}); +export type SessionStartResult = typeof SessionStartResult.Type; + +export const SessionResumeResult = Schema.Struct({ + ...EventWatermark.fields, + state: PublicSessionState, +}); +export type SessionResumeResult = typeof SessionResumeResult.Type; + +export const PublicTurn = Schema.Struct({ + id: Schema.String, + sessionId: Schema.String, + status: Schema.String, + inputEntryId: Schema.optional(Schema.NullOr(Schema.String)), + startedAt: Schema.optional(Schema.Number), + completedAt: Schema.optional(Schema.NullOr(Schema.Number)), + error: Schema.optional(Schema.NullOr(Schema.Unknown)), + stopReason: Schema.optional(Schema.NullOr(Schema.String)), + queueItemId: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type PublicTurn = typeof PublicTurn.Type; + +export const PageResult = Schema.Struct({ + items: Schema.Array(Schema.Unknown), + nextCursor: Schema.optional(Schema.NullOr(Schema.String)), + previousCursor: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export const AccountBudgetView = Schema.Struct({ + usagePercentage: Schema.Number, + resetAt: Schema.optional(Schema.NullOr(Schema.String)), + paygEnabled: Schema.optional(Schema.Boolean), +}); +export const AccountPlanView = Schema.Struct({ + kind: Schema.String, + name: Schema.String, + title: Schema.optional(Schema.NullOr(Schema.String)), +}); +export const AccountView = Schema.Struct({ + status: Schema.String, + plan: Schema.optional(Schema.NullOr(AccountPlanView)), + budget: Schema.optional(Schema.NullOr(AccountBudgetView)), + apiKey: Schema.optional( + Schema.NullOr(Schema.Struct({ preview: Schema.String, scope: Schema.String })), + ), + rateLimitAction: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +export type AccountView = typeof AccountView.Type; +export const AccountReadResult = Schema.Struct({ account: AccountView }); + +export const ConfigModelEntry = Schema.Struct({ + name: Schema.String, + alias: Schema.optional(Schema.String), + displayName: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + thinkingLevels: Schema.optional(Schema.Array(Schema.String)), + supportsImages: Schema.optional(Schema.Boolean), + maxContextLength: Schema.optional(Schema.NullOr(Schema.Number)), +}); +export const ConfigView = Schema.Struct({ + activeModel: Schema.optional(Schema.Unknown), + defaultModelAlias: Schema.optional(Schema.String), + defaultAgent: Schema.optional(Schema.String), + models: Schema.optional(Schema.Array(ConfigModelEntry)), + worktreeLimit: Schema.optional(Schema.Number), +}); +export const ConfigReadResult = Schema.Struct({ config: ConfigView }); +export type ConfigView = typeof ConfigView.Type; + +export const GitCheckout = Schema.Struct({ + repoLocalPath: Schema.String, + ok: Schema.Boolean, + isPrimary: Schema.optional(Schema.Boolean), + repoUrl: Schema.optional(Schema.NullOr(Schema.String)), + root: Schema.optional(Schema.NullOr(Schema.String)), + worktree: Schema.optional(Schema.NullOr(Schema.String)), + branch: Schema.optional(Schema.NullOr(Schema.String)), + baseBranch: Schema.optional(Schema.NullOr(Schema.String)), + message: Schema.optional(Schema.NullOr(Schema.String)), +}); +export const GitCheckoutsResult = Schema.Struct({ + checkouts: Schema.optional(Schema.Array(GitCheckout)), +}); +export type GitCheckout = typeof GitCheckout.Type; + +export const TrustStatusResult = Schema.Struct({ + status: Schema.Literals(["trusted", "session", "untrusted"]), + details: Schema.optional( + Schema.NullOr( + Schema.Struct({ + cwd: Schema.String, + repoRoot: Schema.optional(Schema.NullOr(Schema.String)), + availableDecisions: Schema.optional(Schema.Array(Schema.String)), + }), + ), + ), +}); +export type TrustStatusResult = typeof TrustStatusResult.Type; + +export const EventsReadResult = Schema.Struct({ + type: Schema.optional(Schema.String), + events: Schema.Array(Schema.Unknown), +}); + +export const TurnEnqueueResult = Schema.Struct({ + queueItemId: Schema.String, + waits: Schema.optional(Schema.Boolean), +}); + +export interface VibeAppServerClientOptions { + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: ( + event: VibeProtocol.VibeAppServerProtocolLogEvent, + ) => Effect.Effect; +} + +type ServerRequestHandler = ( + method: string, + params: unknown, +) => Effect.Effect; +type ServerNotificationHandler = ( + method: string, + params: unknown, +) => Effect.Effect; + +export class VibeAppServerClient extends Context.Service< + VibeAppServerClient, + { + readonly awaitTermination: Effect.Effect; + readonly request: ( + method: string, + params?: unknown, + ) => Effect.Effect; + readonly notify: ( + method: string, + params?: unknown, + ) => Effect.Effect; + readonly handleServerRequest: ( + method: string, + handler: ServerRequestHandler, + ) => Effect.Effect; + readonly handleServerNotification: ( + method: string, + handler: ServerNotificationHandler, + ) => Effect.Effect; + readonly handleUnknownServerRequest: (handler: ServerRequestHandler) => Effect.Effect; + readonly handleUnknownServerNotification: ( + handler: ServerNotificationHandler, + ) => Effect.Effect; + + // Typed methods (camelCase wire). + readonly initialize: (input: { + clientName: string; + clientVersion: string; + capabilities?: unknown; + }) => Effect.Effect; + readonly initialized: () => Effect.Effect; + readonly accountRead: ( + sessionId: string, + ) => Effect.Effect; + readonly configRead: ( + sessionId?: string, + ) => Effect.Effect; + readonly configModelWrite: (input: { + sessionId: string; + modelAlias?: string | undefined; + reasoningEffort?: string | undefined; + sessionOnly?: boolean; + }) => Effect.Effect; + readonly sessionStart: ( + params: unknown, + ) => Effect.Effect; + readonly sessionResume: (input: { + sessionId: string; + agentConfig?: unknown; + }) => Effect.Effect; + readonly sessionRead: (input: { + sessionId: string; + history?: unknown; + turns?: unknown; + }) => Effect.Effect; + readonly sessionList: ( + params?: unknown, + ) => Effect.Effect; + readonly sessionDelete: ( + sessionId: string, + ) => Effect.Effect; + readonly sessionStop: (input: { + sessionId: string; + reason?: string | undefined; + }) => Effect.Effect; + readonly sessionTurnEnqueue: (input: { + sessionId: string; + entries: ReadonlyArray; + }) => Effect.Effect; + readonly turnSteer: (input: { + sessionId: string; + expectedTurnId: string; + message: ReadonlyArray; + }) => Effect.Effect; + readonly turnInterrupt: (input: { + sessionId: string; + expectedTurnId: string; + }) => Effect.Effect; + readonly sessionHistoryList: (input: { + sessionId: string; + cursor?: string | null | undefined; + limit?: number; + }) => Effect.Effect; + readonly sessionTurnsList: (input: { + sessionId: string; + cursor?: string | null | undefined; + limit?: number; + }) => Effect.Effect, VibeError.VibeAppServerError>; + readonly eventsRead: (input: { + afterEventId?: number | null | undefined; + sessionIds?: ReadonlyArray; + batchSize?: number; + }) => Effect.Effect, VibeError.VibeAppServerError>; + readonly workspaceTrustStatus: ( + cwd: string, + ) => Effect.Effect; + readonly workspaceTrustDecision: (input: { + decision: string; + cwd?: string | null | undefined; + sessionId?: string | null | undefined; + }) => Effect.Effect; + readonly workspaceGitCheckouts: (input: { + repoLocalPaths: ReadonlyArray; + sessionCwd?: string | null | undefined; + }) => Effect.Effect, VibeError.VibeAppServerError>; + readonly setupStatus: ( + sessionId?: string, + ) => Effect.Effect; + readonly providerAuthRead: ( + sessionId: string, + ) => Effect.Effect; + readonly callbackResult: (input: { + sessionId: string; + callbackId: string; + result: unknown; + }) => Effect.Effect; + } +>()("@t3tools/vibe-app-server/client/VibeAppServerClient") {} + +const decodeTurnItem = Schema.decodeUnknownEffect(PublicTurn as Schema.Codec); + +const decode = + (schema: Schema.Schema, method: string) => + (raw: unknown): Effect.Effect => + Schema.decodeUnknownEffect(schema as Schema.Codec)(raw).pipe( + Effect.mapError( + (error) => + new VibeError.VibeAppServerProtocolError({ + detail: `${method} response failed to decode: ${error.message}`, + cause: error, + }), + ), + ); + +export const make = Effect.fn("@t3tools/vibe-app-server/VibeAppServerClient.make")(function* ( + stdio: Stdio.Stdio, + options: VibeAppServerClientOptions = {}, + terminationError?: Effect.Effect, +): Effect.fn.Return { + const requestHandlers = new Map(); + const notificationHandlers = new Map>(); + let unknownRequestHandler: ServerRequestHandler | undefined; + let unknownNotificationHandler: ServerNotificationHandler | undefined; + + const protocol = yield* VibeProtocol.makeVibeAppServerProtocol({ + stdio, + ...(terminationError === undefined ? {} : { terminationError }), + ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), + ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), + ...(options.logger === undefined ? {} : { logger: options.logger }), + onRequest: (request) => { + if (requestHandlers.has(request.method)) { + return requestHandlers.get(request.method)!(request.method, request.params ?? {}); + } + return unknownRequestHandler !== undefined + ? unknownRequestHandler(request.method, request.params ?? {}) + : Effect.fail( + new VibeError.VibeAppServerRequestError({ + method: request.method, + code: "method_not_found", + detail: `Method not found: ${request.method}`, + }), + ); + }, + onNotification: (notification) => { + const handlers = notificationHandlers.get(notification.method) ?? []; + const unknown = unknownNotificationHandler; + return Effect.forEach( + [...handlers, ...(unknown === undefined ? [] : [unknown])], + (handler) => handler(notification.method, notification.params ?? {}), + { discard: true }, + ).pipe(Effect.asVoid); + }, + }); + + const requestRaw = protocol.request; + + const typed = ( + method: string, + schema: Schema.Schema, + params?: unknown, + ): Effect.Effect => + Effect.flatMap(requestRaw(method, params), decode(schema, method)); + + return VibeAppServerClient.of({ + awaitTermination: protocol.awaitTermination, + request: requestRaw, + notify: protocol.notify, + handleServerRequest: Effect.fn("VibeAppServerClient.handleServerRequest")( + function (method, handler) { + return Effect.sync(() => { + requestHandlers.set(method, handler); + }); + }, + ), + handleServerNotification: Effect.fn("VibeAppServerClient.handleServerNotification")( + function (method, handler) { + return Effect.sync(() => { + const current = notificationHandlers.get(method) ?? []; + current.push(handler); + notificationHandlers.set(method, current); + }); + }, + ), + handleUnknownServerRequest: Effect.fn("VibeAppServerClient.handleUnknownServerRequest")( + function (handler) { + return Effect.sync(() => { + unknownRequestHandler = handler; + }); + }, + ), + handleUnknownServerNotification: Effect.fn( + "VibeAppServerClient.handleUnknownServerNotification", + )(function (handler) { + return Effect.sync(() => { + unknownNotificationHandler = handler; + }); + }), + + initialize: Effect.fn("VibeAppServerClient.initialize")(function* (input) { + return yield* typed("initialize", InitializeResult, { + clientInfo: { name: input.clientName, version: input.clientVersion }, + ...(input.capabilities !== undefined ? { capabilities: input.capabilities } : {}), + }); + }), + initialized: Effect.fn("VibeAppServerClient.initialized")(function* () { + return yield* protocol.notify("initialized", {}); + }), + accountRead: Effect.fn("VibeAppServerClient.accountRead")(function* (sessionId) { + const result = yield* typed("account/read", AccountReadResult, { sessionId }); + return result.account; + }), + configRead: Effect.fn("VibeAppServerClient.configRead")(function* (sessionId) { + const result = yield* typed( + "config/read", + ConfigReadResult, + sessionId === undefined ? {} : { sessionId }, + ); + return result.config; + }), + configModelWrite: Effect.fn("VibeAppServerClient.configModelWrite")(function* (input) { + return yield* requestRaw("config/model/write", { + sessionId: input.sessionId, + ...(input.modelAlias !== undefined ? { modelAlias: input.modelAlias } : {}), + ...(input.reasoningEffort !== undefined ? { reasoningEffort: input.reasoningEffort } : {}), + ...(input.sessionOnly !== undefined ? { sessionOnly: input.sessionOnly } : {}), + }); + }), + sessionStart: Effect.fn("VibeAppServerClient.sessionStart")(function* (params) { + return yield* typed("session/start", SessionStartResult, params); + }), + sessionResume: Effect.fn("VibeAppServerClient.sessionResume")(function* (input) { + return yield* typed("session/resume", SessionResumeResult, { + sessionId: input.sessionId, + ...(input.agentConfig !== undefined ? { agentConfig: input.agentConfig } : {}), + }); + }), + sessionRead: Effect.fn("VibeAppServerClient.sessionRead")(function* (input) { + return yield* typed("session/read", SessionStartResult, { + sessionId: input.sessionId, + ...(input.history !== undefined ? { history: input.history } : {}), + ...(input.turns !== undefined ? { turns: input.turns } : {}), + }); + }), + sessionList: Effect.fn("VibeAppServerClient.sessionList")(function* (params) { + return yield* typed("session/list", PageResult, params ?? {}); + }), + sessionDelete: Effect.fn("VibeAppServerClient.sessionDelete")(function* (sessionId) { + return yield* requestRaw("session/delete", { sessionId }); + }), + sessionStop: Effect.fn("VibeAppServerClient.sessionStop")(function* (input) { + return yield* requestRaw("session/stop", { + sessionId: input.sessionId, + ...(input.reason !== undefined ? { reason: input.reason } : {}), + }); + }), + sessionTurnEnqueue: Effect.fn("VibeAppServerClient.sessionTurnEnqueue")(function* (input) { + return yield* typed("session/turn/enqueue", TurnEnqueueResult, { + sessionId: input.sessionId, + entries: input.entries, + }); + }), + turnSteer: Effect.fn("VibeAppServerClient.turnSteer")(function* (input) { + return yield* requestRaw("turn/steer", { + sessionId: input.sessionId, + expectedTurnId: input.expectedTurnId, + message: input.message, + }); + }), + turnInterrupt: Effect.fn("VibeAppServerClient.turnInterrupt")(function* (input) { + return yield* requestRaw("turn/interrupt", { + sessionId: input.sessionId, + expectedTurnId: input.expectedTurnId, + }); + }), + sessionHistoryList: Effect.fn("VibeAppServerClient.sessionHistoryList")(function* (input) { + return yield* typed("session/history/list", PageResult, { + sessionId: input.sessionId, + ...(input.cursor !== undefined ? { cursor: input.cursor } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }); + }), + sessionTurnsList: Effect.fn("VibeAppServerClient.sessionTurnsList")(function* (input) { + const params: Record = { sessionId: input.sessionId }; + if (input.cursor !== undefined && input.cursor !== null) params["cursor"] = input.cursor; + if (input.limit !== undefined) params["limit"] = input.limit; + const page = yield* typed("session/turns/list", PageResult, params); + return yield* Effect.forEach(page.items, (item) => + decodeTurnItem(item).pipe( + Effect.mapError( + (error) => + new VibeError.VibeAppServerProtocolError({ + detail: `session/turns/list item failed to decode: ${error.message}`, + cause: error, + }), + ), + ), + ); + }), + eventsRead: Effect.fn("VibeAppServerClient.eventsRead")(function* (input) { + const filters: Record = {}; + if (input.sessionIds !== undefined) filters["sessionIds"] = [...input.sessionIds]; + const params: Record = { filters }; + if (input.afterEventId !== undefined && input.afterEventId !== null) { + params["afterEventId"] = input.afterEventId; + } + if (input.batchSize !== undefined) params["batchSize"] = input.batchSize; + const result = yield* typed("events/read", EventsReadResult, params); + return result.events; + }), + workspaceTrustStatus: Effect.fn("VibeAppServerClient.workspaceTrustStatus")(function* (cwd) { + return yield* typed("workspace/trust/status", TrustStatusResult, { cwd }); + }), + workspaceTrustDecision: Effect.fn("VibeAppServerClient.workspaceTrustDecision")( + function* (input) { + return yield* requestRaw("workspace/trust/decision", { + decision: input.decision, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + }); + }, + ), + workspaceGitCheckouts: Effect.fn("VibeAppServerClient.workspaceGitCheckouts")( + function* (input) { + const result = yield* typed("workspace/git/checkouts", GitCheckoutsResult, { + repoLocalPaths: [...input.repoLocalPaths], + ...(input.sessionCwd !== undefined ? { sessionCwd: input.sessionCwd } : {}), + }); + return result.checkouts ?? []; + }, + ), + setupStatus: Effect.fn("VibeAppServerClient.setupStatus")(function* (sessionId) { + return yield* requestRaw("setup/status", sessionId === undefined ? {} : { sessionId }); + }), + providerAuthRead: Effect.fn("VibeAppServerClient.providerAuthRead")(function* (sessionId) { + return yield* requestRaw("providerAuth/read", { sessionId }); + }), + callbackResult: Effect.fn("VibeAppServerClient.callbackResult")(function* (input) { + return yield* requestRaw("callback/result", { + sessionId: input.sessionId, + callbackId: input.callbackId, + result: input.result, + }); + }), + }); +}); + +export const layerChildProcess = ( + handle: ChildProcessSpawner.ChildProcessHandle, + options: VibeAppServerClientOptions = {}, +): Layer.Layer => + Layer.effect(VibeAppServerClient, makeChildProcessClient(handle, options)); + +const makeChildProcessClient = Effect.fn( + "@t3tools/vibe-app-server/VibeAppServerClient.makeChildProcessClient", +)(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: VibeAppServerClientOptions) { + const encoder = new TextEncoder(); + const stdio = Stdio.make({ + args: Effect.succeed([]), + stdin: handle.stdout, + stdout: () => + Sink.mapInput(handle.stdin, (chunk: string | Uint8Array) => + typeof chunk === "string" ? encoder.encode(chunk) : chunk, + ), + stderr: () => Sink.drain, + }); + const terminationError = handle.exitCode.pipe( + Effect.map( + (code): VibeError.VibeAppServerError => + new VibeError.VibeAppServerTerminatedError({ exitCode: Number(code) }), + ), + Effect.catchCause((cause): Effect.Effect => + Effect.succeed(new VibeError.VibeAppServerTerminatedError({ exitCode: null, cause })), + ), + ); + return yield* make(stdio, options, terminationError); +}); + +/** In-memory stdio pair for tests: feed server output via `input`, read client writes via `output`. */ +export const makeInMemoryStdio = Effect.fn("@t3tools/vibe-app-server/makeInMemoryStdio")( + function* () { + const input = yield* Queue.unbounded(); + const output = yield* Queue.unbounded(); + const decoder = new TextDecoder(); + const stdio = Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.fromQueue(input), + stdout: () => + Sink.forEach((chunk: string | Uint8Array) => + Queue.offer( + output, + typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }), + ), + ), + stderr: () => Sink.drain, + }); + return { stdio, input, output }; + }, +); diff --git a/packages/vibe-app-server/src/errors.ts b/packages/vibe-app-server/src/errors.ts new file mode 100644 index 0000000000000..bcf4dad8621ab --- /dev/null +++ b/packages/vibe-app-server/src/errors.ts @@ -0,0 +1,61 @@ +import * as Schema from "effect/Schema"; + +/** JSON-RPC error codes used by vibe-app-server (string-valued enum on the wire). */ +export const VibeErrorCode = Schema.Literals([ + "parse_error", + "invalid_request", + "method_not_found", + "invalid_params", + "internal_error", + "not_initialized", + "conflict", + "unauthorized", + "forbidden", + "not_found", + "server_error", +]); +export type VibeErrorCode = typeof VibeErrorCode.Type; + +export class VibeAppServerRequestError extends Schema.TaggedError()( + "VibeAppServerRequestError", + { + method: Schema.String, + code: Schema.String, + detail: Schema.String, + data: Schema.optional(Schema.Unknown), + }, +) { + override get message(): string { + return `${this.method} failed (${this.code}): ${this.detail}`; + } +} + +export class VibeAppServerProtocolError extends Schema.TaggedError()( + "VibeAppServerProtocolError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `vibe-app-server protocol error: ${this.detail}`; + } +} + +export class VibeAppServerTerminatedError extends Schema.TaggedError()( + "VibeAppServerTerminatedError", + { + exitCode: Schema.NullOr(Schema.Number), + stderrTail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `vibe-app-server exited (code ${this.exitCode ?? "unknown"}).`; + } +} + +export type VibeAppServerError = + | VibeAppServerRequestError + | VibeAppServerProtocolError + | VibeAppServerTerminatedError; diff --git a/packages/vibe-app-server/src/live.test.ts b/packages/vibe-app-server/src/live.test.ts new file mode 100644 index 0000000000000..43a3b5dc81641 --- /dev/null +++ b/packages/vibe-app-server/src/live.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as VibeClient from "./client.ts"; +import * as VibeError from "./errors.ts"; + +const isProtocolError = Schema.is(VibeError.VibeAppServerProtocolError); + +describe("vibe-app-server live smoke (skips when the binary is absent)", () => { + it.effect("initialize, ephemeral session in cwd, config + git checkouts read, delete", () => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner + .spawn(ChildProcess.make("vibe-app-server", [], { cwd: process.cwd() })) + .pipe( + Effect.catchCause((cause) => + Effect.fail( + new VibeError.VibeAppServerProtocolError({ + detail: `skip: vibe-app-server is not installed (${String(Cause.squash(cause))})`, + cause, + }), + ), + ), + ); + const client = Context.get( + yield* Layer.build(VibeClient.layerChildProcess(child)), + VibeClient.VibeAppServerClient, + ); + + const init = yield* client.initialize({ clientName: "t3-test", clientVersion: "0.0.0" }); + expect(init.serverInfo.name).toBe("vibe-app-server"); + expect(init.serverInfo.version.length).toBeGreaterThan(0); + yield* client.initialized(); + + const started = yield* client.sessionStart({ + kind: "ephemeral", + agentConfig: { cwd: process.cwd(), headless: true, trustWorkspace: true }, + }); + const sessionId = started.state.session.id; + expect(sessionId.length).toBeGreaterThan(0); + expect(started.state.session.cwd).toBe(process.cwd()); + + const config = yield* client.configRead(sessionId); + expect((config.models ?? []).length).toBeGreaterThan(0); + + const checkouts = yield* client.workspaceGitCheckouts({ + repoLocalPaths: [process.cwd()], + sessionCwd: process.cwd(), + }); + expect(checkouts.length).toBe(1); + expect(checkouts[0]?.ok).toBe(true); + + const account = yield* client.accountRead(sessionId); + expect(["ready", "missing_key", "unauthorized", "unavailable"]).toContain(account.status); + + yield* client.sessionDelete(sessionId); + }).pipe( + Effect.catchCause((cause) => { + const squashed = Cause.squash(cause); + if (isProtocolError(squashed) && squashed.detail.startsWith("skip:")) { + return Effect.logWarning(squashed.detail); + } + return Effect.failCause(cause); + }), + Effect.provide(NodeServices.layer), + ), + ), + ); +}); diff --git a/packages/vibe-app-server/src/protocol.ts b/packages/vibe-app-server/src/protocol.ts new file mode 100644 index 0000000000000..5d5de041cc5f8 --- /dev/null +++ b/packages/vibe-app-server/src/protocol.ts @@ -0,0 +1,346 @@ +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; + +import * as VibeError from "./errors.ts"; + +/** + * vibe-app-server speaks JSON-RPC 2.0 over stdio, one compact JSON message per + * line (newline-delimited), with camelCase fields (pydantic `to_camel` aliases). + */ + +export interface JsonRpcRequest { + readonly jsonrpc: "2.0"; + readonly id: number | string; + readonly method: string; + readonly params?: unknown; +} + +export interface JsonRpcNotification { + readonly jsonrpc: "2.0"; + readonly method: string; + readonly params?: unknown; +} + +export interface JsonRpcSuccess { + readonly jsonrpc: "2.0"; + readonly id: number | string; + readonly result: unknown; +} + +export interface JsonRpcFailure { + readonly jsonrpc: "2.0"; + readonly id: number | string; + readonly error: { + readonly code: string | number; + readonly message: string; + readonly data?: unknown; + }; +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcSuccess | JsonRpcFailure; + +export const isJsonRpcRequest = (message: JsonRpcMessage): message is JsonRpcRequest => + "method" in message && "id" in message; + +export const isJsonRpcNotification = (message: JsonRpcMessage): message is JsonRpcNotification => + "method" in message && !("id" in message); + +export const isJsonRpcSuccess = (message: JsonRpcMessage): message is JsonRpcSuccess => + "result" in message; + +export const isJsonRpcFailure = (message: JsonRpcMessage): message is JsonRpcFailure => + "error" in message; + +export interface VibeAppServerProtocolLogEvent { + readonly direction: "incoming" | "outgoing"; + readonly method?: string | undefined; + readonly raw: string; +} + +export interface VibeAppServerProtocolOptions { + readonly stdio: Stdio.Stdio; + readonly onRequest?: ( + request: JsonRpcRequest, + ) => Effect.Effect; + readonly onNotification?: ( + notification: JsonRpcNotification, + ) => Effect.Effect; + readonly onTermination?: (error: VibeError.VibeAppServerError) => Effect.Effect; + readonly terminationError?: Effect.Effect; + readonly logIncoming?: boolean; + readonly logOutgoing?: boolean; + readonly logger?: (event: VibeAppServerProtocolLogEvent) => Effect.Effect; +} + +export interface VibeAppServerProtocol { + readonly request: ( + method: string, + params?: unknown, + ) => Effect.Effect; + readonly notify: ( + method: string, + params?: unknown, + ) => Effect.Effect; + readonly respond: ( + id: number | string, + result: unknown, + ) => Effect.Effect; + readonly respondError: ( + id: number | string, + code: string | number, + message: string, + data?: unknown, + ) => Effect.Effect; + readonly incomingRequests: Stream.Stream; + readonly incomingNotifications: Stream.Stream; + readonly awaitTermination: Effect.Effect; +} + +const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const isRequestError = Schema.is(VibeError.VibeAppServerRequestError); + +export const parseJsonRpcLine = (line: string): JsonRpcMessage | undefined => { + const trimmed = line.trim(); + if (trimmed.length === 0) return undefined; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null) return undefined; + return parsed as JsonRpcMessage; + } catch { + return undefined; + } +}; + +export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/protocol.make")( + function* ( + options: VibeAppServerProtocolOptions, + ): Effect.fn.Return { + const pending = new Map< + string | number, + Deferred.Deferred + >(); + const requestQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); + const termination = yield* Deferred.make(); + let nextId = 1; + + const log = (direction: "incoming" | "outgoing", raw: string, method?: string) => { + const enabled = direction === "incoming" ? options.logIncoming : options.logOutgoing; + if (!enabled || !options.logger) return Effect.void; + return options.logger({ direction, method, raw }); + }; + + const enqueueOutgoing = ( + message: Record, + ): Effect.Effect => + Effect.flatMap(encodeJson(message), (raw) => + Effect.andThen( + log( + "outgoing", + raw, + typeof message["method"] === "string" ? message["method"] : undefined, + ), + Queue.offer(outgoing, `${raw}\n`), + ), + ).pipe( + Effect.asVoid, + Effect.mapError( + (error) => + new VibeError.VibeAppServerProtocolError({ + detail: `Failed to encode outgoing message: ${error.message}`, + cause: error, + }), + ), + ); + + const failTermination = (error: VibeError.VibeAppServerError) => + Effect.andThen( + Deferred.fail(termination, error), + options.onTermination ? options.onTermination(error) : Effect.void, + ); + + const dispatch = ( + message: JsonRpcMessage, + ): Effect.Effect => { + if (isJsonRpcSuccess(message) || isJsonRpcFailure(message)) { + const deferred = pending.get(message.id); + if (deferred === undefined) return Effect.void; + pending.delete(message.id); + return isJsonRpcSuccess(message) + ? Effect.asVoid(Deferred.succeed(deferred, message.result)) + : Effect.asVoid( + Deferred.fail( + deferred, + new VibeError.VibeAppServerRequestError({ + method: "", + code: String(message.error.code), + detail: message.error.message, + ...(message.error.data !== undefined ? { data: message.error.data } : {}), + }), + ), + ); + } + if (isJsonRpcRequest(message)) { + const answer: Effect.Effect> = + options.onRequest === undefined + ? Effect.succeed({ + error: { code: "method_not_found", message: `Method not found: ${message.method}` }, + }) + : options.onRequest(message).pipe( + Effect.map((result) => ({ result })), + Effect.catchCause((cause) => { + const squashed: unknown = Cause.squash(cause); + const error = isRequestError(squashed) ? squashed : undefined; + return Effect.succeed({ + error: { + code: error !== undefined ? String(error.code) : "internal_error", + message: error !== undefined ? error.detail : String(squashed), + }, + }); + }), + ); + return Effect.asVoid( + Effect.andThen( + Queue.offer(requestQueue, message), + Effect.flatMap(answer, (response) => + "result" in response + ? enqueueOutgoing({ jsonrpc: "2.0", id: message.id, result: response.result }) + : enqueueOutgoing({ jsonrpc: "2.0", id: message.id, error: response.error }), + ), + ), + ); + } + if (isJsonRpcNotification(message)) { + return Effect.asVoid( + Effect.andThen( + Queue.offer(notificationQueue, message), + options.onNotification === undefined + ? Effect.void + : Effect.ignore(options.onNotification(message)), + ), + ); + } + return Effect.void; + }; + + yield* options.stdio.stdin.pipe( + Stream.decodeText(), + Stream.runForEach( + Effect.fnUntraced(function* (chunk) { + let start = 0; + let remainder = ""; + while (start < chunk.length) { + const newline = chunk.indexOf("\n", start); + const fragment = chunk.slice(start, newline === -1 ? chunk.length : newline); + remainder += fragment; + if (newline === -1) break; + const line = remainder.replace(/\r$/, ""); + remainder = ""; + start = newline + 1; + if (line.trim().length === 0) continue; + yield* log("incoming", line); + const message = parseJsonRpcLine(line); + if (message !== undefined) { + yield* Effect.catchCause(dispatch(message), (cause) => + Effect.logWarning("vibe-app-server dispatch failed", { cause }), + ); + } + } + }), + ), + Effect.onExit((exit) => + options.terminationError !== undefined + ? Effect.flatMap(options.terminationError, failTermination) + : failTermination( + new VibeError.VibeAppServerProtocolError({ + detail: + exit._tag === "Failure" + ? "vibe-app-server stdio failed" + : "vibe-app-server stdio ended", + ...(exit._tag === "Failure" ? { cause: exit.cause } : {}), + }), + ), + ), + Effect.forkScoped, + ); + + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(options.stdio.stdout()), + Effect.onExit((exit) => + exit._tag === "Failure" + ? failTermination( + new VibeError.VibeAppServerProtocolError({ + detail: "vibe-app-server stdin write failed", + cause: exit.cause, + }), + ) + : Effect.void, + ), + Effect.forkScoped, + ); + + const request = Effect.fn("@t3tools/vibe-app-server/protocol.request")(function* ( + method: string, + params?: unknown, + ) { + const id = nextId++; + const deferred = yield* Deferred.make(); + pending.set(id, deferred); + yield* enqueueOutgoing({ + jsonrpc: "2.0", + id, + method, + ...(params !== undefined ? { params } : {}), + }); + return yield* Effect.mapError(Deferred.await(deferred), (error) => + error.method.length === 0 + ? new VibeError.VibeAppServerRequestError({ + method, + code: error.code, + detail: error.detail, + ...(error.data !== undefined ? { data: error.data } : {}), + }) + : error, + ); + }); + + const notify = (method: string, params?: unknown) => + enqueueOutgoing({ + jsonrpc: "2.0", + method, + ...(params !== undefined ? { params } : {}), + }); + + const respond = (id: number | string, result: unknown) => + enqueueOutgoing({ jsonrpc: "2.0", id, result }); + + const respondError = ( + id: number | string, + code: string | number, + message: string, + data?: unknown, + ) => + enqueueOutgoing({ + jsonrpc: "2.0", + id, + error: { code, message, ...(data !== undefined ? { data } : {}) }, + }); + + return { + request, + notify, + respond, + respondError, + incomingRequests: Stream.fromQueue(requestQueue), + incomingNotifications: Stream.fromQueue(notificationQueue), + awaitTermination: Deferred.await(termination), + }; + }, +); diff --git a/packages/vibe-app-server/tsconfig.json b/packages/vibe-app-server/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/vibe-app-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e1dd4d26a461..144f8970d5f4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/provider-vibe': + specifier: workspace:* + version: link:../../packages/provider-vibe '@t3tools/source-control-azure-devops': specifier: workspace:* version: link:../../packages/source-control-azure-devops @@ -1515,6 +1518,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../provider-pi + '@t3tools/provider-vibe': + specifier: workspace:* + version: link:../provider-vibe '@t3tools/secret-provider-definitions': specifier: workspace:* version: link:../secret-provider-definitions @@ -1975,6 +1981,34 @@ importers: specifier: 24.12.4 version: 24.12.4 + packages/provider-vibe: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/vibe-app-server': + specifier: workspace:* + version: link:../vibe-app-server + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/secret-provider-core: dependencies: '@t3tools/contracts': @@ -2426,6 +2460,25 @@ importers: specifier: 1.4.0 version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/vibe-app-server: + dependencies: + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/platform-node': + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + scripts: dependencies: '@effect/platform-node': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index a586d67ef6035..761fa2533fb75 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -37,6 +37,8 @@ const workspaceFiles = [ "packages/provider-cursor/package.json", "packages/provider-fx/package.json", "packages/provider-grok/package.json", + "packages/provider-vibe/package.json", + "packages/vibe-app-server/package.json", "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", From 1384e36fecb707a1db193f7846119db42a787d2f Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 09:47:37 +0100 Subject: [PATCH 02/18] fix(provider-vibe): address review comments on #31881 - adapter: attach runId/runAttemptId/rootNodeId to turn, message and item projections; handle history/entryUpdated via stored entries + JSON patch; surface callback/call server requests as live runtime_request events; fail the event stream when the app server exits; reject thread_start rollbacks instead of returning an unchanged snapshot; map the thinking option to reasoningEffort on session start and model writes - vibe-app-server client: page envelope for history/turns list, nested callback/result { callbackId, output }, pending requests failed and new requests raced on termination, partial lines kept across chunks, incoming messages published via PubSub instead of retaining queues - status probe: timeout wraps the whole scoped probe; probes never mutate workspace trust - text generation: only assistant entries are parsed and non-completed terminal turns are errors Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 317 ++++++++++++++---- packages/provider-vibe/src/server/status.ts | 36 +- .../src/server/textGeneration.ts | 22 +- packages/vibe-app-server/src/client.test.ts | 22 ++ packages/vibe-app-server/src/client.ts | 23 +- packages/vibe-app-server/src/protocol.ts | 70 +++- 6 files changed, 375 insertions(+), 115 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index c33e924f5f957..be803916808ef 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -2,7 +2,12 @@ import { NodeId, ProviderInstanceId, ProviderTurnId, + RunId, + RunAttemptId, + RuntimeRequestId, + type ThreadId, type OrchestrationV2ConversationMessage, + type OrchestrationV2RuntimeRequest, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, @@ -22,6 +27,8 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as VibeAppServer from "@t3tools/vibe-app-server/client"; +import * as VibeAppServerError from "@t3tools/vibe-app-server/errors"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -165,6 +172,38 @@ function entryText(entry: unknown): string { .join(""); } +/** Minimal JSON Patch (RFC 6902 subset: add/replace/remove on `/`-separated paths). */ +function applyJsonPatch( + target: Record, + patch: ReadonlyArray, +): Record { + const result: Record = JSON.parse(JSON.stringify(target)) as Record< + string, + unknown + >; + for (const operation of patch) { + const op = asRecord(operation); + if (!op) continue; + const path = str(op["path"]) ?? ""; + const segments = path.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0) continue; + let cursor: Record | unknown[] = result; + for (const segment of segments.slice(0, -1)) { + const next = (cursor as Record)[segment]; + if (typeof next !== "object" || next === null) break; + cursor = next as Record; + } + const key = segments[segments.length - 1]!; + if (typeof cursor !== "object" || cursor === null) continue; + if (op["op"] === "remove") { + delete (cursor as Record)[key]; + } else { + (cursor as Record)[key] = op["value"]; + } + } + return result; +} + /** Maps a vibe turn error/stop reason to the native failure contract; `-31001` is Vibe's rate limit. */ export function vibeTurnFailure(turn: { readonly status?: unknown; @@ -196,6 +235,18 @@ interface AdapterState { | undefined; turnOrdinals: Map; lastRunOrdinal: number; + /** The current run's identity, attached to every turn/message/item projection. */ + currentRun: + | { + readonly runId: RunId; + readonly runAttemptId: RunAttemptId; + readonly rootNodeId: NodeId; + } + | undefined; + /** History entries by native id, so `history/entryUpdated` patches can be applied. */ + entries: Map>; + /** Maps a T3 runtime-request id to the native callback id answered via `callback/result`. */ + callbackByRequest: Map; } export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( @@ -248,6 +299,70 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.asVoid(PubSub.publish(events, event)); + /** Projects a stored history entry to message + turn-item events, tagged with the current run. */ + const projectHistoryEntry = Effect.fn("VibeAdapterV2.projectHistoryEntry")(function* ( + entry: Record, + nativeItemId: string, + threadId: ThreadId, + ) { + if (!state.thread) return; + const role = str(entry["role"]); + const text = entryText(entry); + if (!text) return; + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }); + const timestamp = yield* DateTime.now; + const message: OrchestrationV2ConversationMessage = { + createdBy: role === "user" ? "user" : "agent", + creationSource: "provider", + id: messageId, + threadId, + runId: state.currentRun?.runId ?? null, + nodeId: state.currentRun?.rootNodeId ?? null, + role: role === "user" ? "user" : "assistant", + text, + attachments: [], + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + }; + const item = { + id: turnItemId, + threadId, + runId: state.currentRun?.runId ?? null, + nodeId: state.currentRun?.rootNodeId ?? null, + providerThreadId: state.thread.id, + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeItemRef: nativeRef(nativeItemId), + parentItemId: null, + ordinal: 0, + status: "completed" as const, + title: null, + startedAt: null, + completedAt: timestamp, + updatedAt: timestamp, + type: role === "user" ? ("user_message" as const) : ("assistant_message" as const), + messageId, + text, + textOmitted: true as const, + ...(role === "user" + ? { inputIntent: "turn_start" as const, attachments: [] as const } + : { streaming: false as const }), + }; + yield* emit({ type: "message.updated", driver: VIBE_PROVIDER, message }); + yield* emit({ + type: "turn_item.updated", + driver: VIBE_PROVIDER, + turnItem: item as OrchestrationV2TurnItem, + }); + }); + const now = yield* DateTime.now; const providerSessionId = idAllocator.derive.providerSession({ providerInstanceId: options.instanceId, @@ -269,6 +384,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( activeTurn: undefined, turnOrdinals: new Map(), lastRunOrdinal: 0, + currentRun: undefined, + entries: new Map(), + callbackByRequest: new Map(), }; const sessionIdOf = () => state.thread?.nativeThreadRef?.nativeId ?? undefined; @@ -313,8 +431,8 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const providerTurn: OrchestrationV2ProviderTurn = { id: providerTurnId, providerThreadId: state.thread.id, - nodeId: state.thread.ownerNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), - runAttemptId: null, + nodeId: state.currentRun?.rootNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), + runAttemptId: state.currentRun?.runAttemptId ?? null, nativeTurnRef: nativeRef(nativeTurnId), ordinal, status: "running", @@ -352,8 +470,8 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( providerTurn: { id: providerTurnId, providerThreadId: state.thread.id, - nodeId: state.thread.ownerNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), - runAttemptId: null, + nodeId: state.currentRun?.rootNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), + runAttemptId: state.currentRun?.runAttemptId ?? null, nativeTurnRef: nativeRef(nativeTurnId), ordinal: state.turnOrdinals.get(nativeTurnId) ?? 1, status: terminalStatus === "failed" ? "failed" : terminalStatus, @@ -396,67 +514,79 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( state.activeTurn = undefined; return; } - if (method === "history/entryAdded" || method === "history/entryUpdated") { + if (method === "history/entryAdded") { const entry = asRecord(record["entry"]); if (!entry || !state.thread) return; - const role = str(entry["role"]); - const text = entryText(entry); - if (!text) return; const nativeItemId = str(entry["id"]) ?? `${method}:${yield* random.uuidV4()}`; - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: VIBE_PROVIDER, - nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: VIBE_PROVIDER, - nativeItemId, + state.entries.set(nativeItemId, entry); + yield* projectHistoryEntry(entry, nativeItemId, input.threadId); + return; + } + if (method === "history/entryUpdated") { + // Updates carry `entryId` + a JSON patch, not the full entry: apply the patch to the stored entry. + const entryId = str(record["entryId"]); + const stored = entryId !== undefined ? state.entries.get(entryId) : undefined; + if (!entryId || !stored || !state.thread) return; + const patch = Array.isArray(record["patch"]) ? record["patch"] : []; + const patched = applyJsonPatch(stored, patch); + state.entries.set(entryId, patched); + yield* projectHistoryEntry(patched, entryId, input.threadId); + return; + } + }), + ); + + // Approvals and structured questions arrive as the `callback/call` server request: + // surface them as a live runtime request, ack the call, and answer later via `callback/result`. + yield* client.handleUnknownServerRequest( + Effect.fn("VibeAdapterV2.onServerRequest")(function* (method: string, params: unknown) { + const toWireError = (cause: unknown) => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "internal_error", + detail: cause instanceof Error ? cause.message : String(cause), }); - const timestamp = yield* DateTime.now; - const message: OrchestrationV2ConversationMessage = { - createdBy: role === "user" ? "user" : "agent", - creationSource: "provider", - id: messageId, - threadId: input.threadId, - runId: null, - nodeId: null, - role: role === "user" ? "user" : "assistant", - text, - attachments: [], - streaming: false, - createdAt: timestamp, - updatedAt: timestamp, - }; - const item = { - id: turnItemId, - threadId: input.threadId, - runId: null, - nodeId: null, - providerThreadId: state.thread.id, - providerTurnId: state.activeTurn?.providerTurnId ?? null, - nativeItemRef: nativeRef(nativeItemId), - parentItemId: null, - ordinal: 0, - status: "completed" as const, - title: null, - startedAt: null, - completedAt: timestamp, - updatedAt: timestamp, - type: role === "user" ? ("user_message" as const) : ("assistant_message" as const), - messageId, - text, - textOmitted: true as const, - ...(role === "user" - ? { inputIntent: "turn_start" as const, attachments: [] as const } - : { streaming: false as const }), - }; - yield* emit({ type: "message.updated", driver: VIBE_PROVIDER, message }); - yield* emit({ - type: "turn_item.updated", - driver: VIBE_PROVIDER, - turnItem: item as OrchestrationV2TurnItem, + if (method !== "callback/call") { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "method_not_found", + detail: `Unsupported vibe-app-server request: ${method}`, }); - return; } + const callback = asRecord(asRecord(params)?.["callback"]); + const callbackId = str(callback?.["callbackId"]) ?? str(callback?.["callback_id"]); + if (!callbackId) { + return { accepted: false }; + } + const requestId = yield* idAllocator.allocate + .runtimeRequest({ + driver: VIBE_PROVIDER, + ...(state.activeTurn !== undefined + ? { providerTurnId: state.activeTurn.providerTurnId } + : {}), + nativeRequestId: callbackId, + }) + .pipe(Effect.mapError(toWireError)); + state.callbackByRequest.set(requestId, callbackId); + const createdAt = yield* DateTime.now; + const kind = str(callback?.["kind"]) ?? "permission"; + yield* emit({ + type: "runtime_request.updated", + driver: VIBE_PROVIDER, + threadId: input.threadId, + runtimeRequest: { + id: requestId, + nodeId: state.currentRun?.rootNodeId ?? NodeId.make(`vibe-request-${requestId}`), + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeRequestRef: nativeRef(callbackId), + kind: kind === "user_input" ? "permission" : "permission", + status: "pending", + responseCapability: { type: "live", providerSessionId }, + createdAt, + resolvedAt: null, + } satisfies OrchestrationV2RuntimeRequest, + }); + return { callbackId, accepted: true }; }), ); @@ -492,6 +622,15 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( sessionOnly: true, } : {}), + ...(getModelSelectionStringOptionValue(ensureInput.modelSelection, "thinking") !== + undefined + ? { + reasoningEffort: getModelSelectionStringOptionValue( + ensureInput.modelSelection, + "thinking", + ), + } + : {}), }), protocolError, ); @@ -538,10 +677,16 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); } state.lastRunOrdinal = turnInput.runOrdinal; + state.currentRun = { + runId: turnInput.runId, + runAttemptId: turnInput.attemptId, + rootNodeId: turnInput.rootNodeId, + }; yield* Effect.mapError( client.configModelWrite({ sessionId: nativeSessionId, modelAlias: resolveVibeModelId(turnInput.modelSelection.model), + reasoningEffort: getModelSelectionStringOptionValue(turnInput.modelSelection, "thinking"), sessionOnly: true, }), protocolError, @@ -602,6 +747,8 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); } const decision = responseInput.decision ?? "cancel"; + const callbackId = + state.callbackByRequest.get(responseInput.requestId) ?? responseInput.requestId; const result = decision === "cancel" ? { outcome: "cancelled" as const } @@ -614,11 +761,32 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( yield* Effect.mapError( client.callbackResult({ sessionId: nativeSessionId, - callbackId: responseInput.requestId, + callbackId, result, }), protocolError, ); + state.callbackByRequest.delete(responseInput.requestId); + const resolvedAt = yield* DateTime.now; + yield* emit({ + type: "runtime_request.updated", + driver: VIBE_PROVIDER, + runtimeRequest: { + id: responseInput.requestId, + nodeId: + state.currentRun?.rootNodeId ?? + NodeId.make(`vibe-request-${responseInput.requestId}`), + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeRequestRef: nativeRef(callbackId), + kind: "permission", + status: "resolved", + responseCapability: { type: "live", providerSessionId }, + createdAt: resolvedAt, + resolvedAt, + decision, + ...(responseInput.answers ? { answers: responseInput.answers } : {}), + } satisfies OrchestrationV2RuntimeRequest, + }); }); const readThreadSnapshot: ProviderAdapter.ProviderAdapterV2SessionRuntime["readThreadSnapshot"] = @@ -694,14 +862,21 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( providerThreadId: rollbackInput.providerThread.id, }); } - if (rollbackInput.target.type === "provider_turn") { - const entryId = rollbackInput.target.providerTurn.nativeTurnRef?.nativeId; - if (entryId) { - yield* Effect.mapError( - client.request("session/rewind", { sessionId: nativeSessionId, entryId }), - protocolError, - ).pipe(Effect.ignore); - } + if (rollbackInput.target.type === "thread_start") { + // Vibe has no native "reset conversation" call; report it as unsupported instead of + // returning an unchanged snapshot that would leave history intact. + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + }); + } + const entryId = rollbackInput.target.providerTurn.nativeTurnRef?.nativeId; + if (entryId) { + yield* Effect.mapError( + client.request("session/rewind", { sessionId: nativeSessionId, entryId }), + protocolError, + ).pipe(Effect.ignore); } return yield* readThreadSnapshot({ providerThread: rollbackInput.providerThread }); }); @@ -763,7 +938,11 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( driver: VIBE_PROVIDER, providerSessionId, providerSession: state.session, - events: Stream.fromPubSub(events), + // Fail the event stream when the app-server process dies so pumps don't wait forever. + events: Stream.merge( + Stream.fromPubSub(events), + Stream.fromEffect(Effect.mapError(client.awaitTermination, protocolError)), + ), ensureThread, resumeThread: (resumeInput) => ensureThread({ diff --git a/packages/provider-vibe/src/server/status.ts b/packages/provider-vibe/src/server/status.ts index a6c700abb128c..bbfeeabf878b4 100644 --- a/packages/provider-vibe/src/server/status.ts +++ b/packages/provider-vibe/src/server/status.ts @@ -158,6 +158,8 @@ const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* probeCwd: string, ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); + // The timeout wraps the whole scoped probe, including initialize, so a stalled + // app server cannot hold the refresh semaphore forever. return yield* Effect.scoped( withVibeAppServerClient( { @@ -174,7 +176,8 @@ const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* agentConfig: { cwd: probeCwd, headless: true, - trustWorkspace: settings.trustWorkspace, + // Probes must not mutate the user's persisted workspace trust. + trustWorkspace: false, }, }); const sessionId = started.state.session.id; @@ -191,24 +194,23 @@ const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* defaultAgent: config.defaultAgent ?? undefined, installed: true, } satisfies VibeProbeResult; - }).pipe( - Effect.timeoutOption(PROBE_TIMEOUT_MS), - Effect.map((option) => - option._tag === "None" - ? ({ - version: null, - auth: { status: "unknown" }, - usageLimits: undefined, - models: [...FALLBACK_MODELS], - defaultAgent: undefined, - installed: true, - message: "Mistral Vibe app server timed out while probing.", - } satisfies VibeProbeResult) - : option.value, - ), - ), + }), ), ).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.map((option) => + option._tag === "None" + ? ({ + version: null, + auth: { status: "unknown" }, + usageLimits: undefined, + models: [...FALLBACK_MODELS], + defaultAgent: undefined, + installed: true, + message: "Mistral Vibe app server timed out while probing.", + } satisfies VibeProbeResult) + : option.value, + ), Effect.catchCause((cause) => Effect.succeed({ version: null, diff --git a/packages/provider-vibe/src/server/textGeneration.ts b/packages/provider-vibe/src/server/textGeneration.ts index 43cd9643a5c46..67a64afcee2ee 100644 --- a/packages/provider-vibe/src/server/textGeneration.ts +++ b/packages/provider-vibe/src/server/textGeneration.ts @@ -19,8 +19,17 @@ interface HistoryEntryLike { readonly role?: unknown; readonly type?: unknown; readonly content?: unknown; + readonly error?: unknown; } +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const str = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; + function entryText(entry: HistoryEntryLike): string { if (entry.type !== "message" || !Array.isArray(entry.content)) return ""; return entry.content @@ -40,7 +49,7 @@ const waitForTurnTerminal = Effect.fn("VibeTextGeneration.waitForTurnTerminal")( while (true) { const turns = yield* client.sessionTurnsList({ sessionId, limit: 20 }); const active = turns.find((turn) => !TERMINAL_TURN_STATUSES.has(turn.status)); - if (active === undefined) return; + if (active === undefined) return turns[0]; yield* Effect.sleep("500 millis"); } }); @@ -75,10 +84,19 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi sessionId, entries: [{ role: "user", content: [{ type: "text", text: prompt }] }], }); - yield* waitForTurnTerminal(client, sessionId); + const terminalTurn = yield* waitForTurnTerminal(client, sessionId); + if (terminalTurn === undefined || terminalTurn.status !== "completed") { + const error = asRecord(terminalTurn?.error); + return yield* new TextGenerationError({ + operation, + detail: `Mistral Vibe turn ended as ${terminalTurn?.status ?? "unknown"}: ${str(error?.["message"]) ?? "no assistant reply"}`, + }); + } const history = yield* client.sessionHistoryList({ sessionId, limit: 100 }); + // Only assistant messages are replies; the user's prompt must never be parsed as output. const reply = [...history.items] .reverse() + .filter((item) => (item as HistoryEntryLike).role === "assistant") .map((item) => entryText(item as HistoryEntryLike)) .find((text) => text.length > 0); yield* client.sessionDelete(sessionId).pipe(Effect.ignore); diff --git a/packages/vibe-app-server/src/client.test.ts b/packages/vibe-app-server/src/client.test.ts index 6fdb7d703d9c0..ac9d2b8e6af36 100644 --- a/packages/vibe-app-server/src/client.test.ts +++ b/packages/vibe-app-server/src/client.test.ts @@ -40,6 +40,8 @@ const serverLine = (input: Queue.Queue, payload: unknown) => Queue.offer(input, encoder.encode(`${JSON.stringify(payload)}\n`)); /** Fake vibe-app-server: answers initialize/account/read, fails session/stop, notifies on anything else. */ +const seenRequests: Array<{ method: string; params: unknown }> = []; + const fakeServer = ( output: Queue.Queue, input: Queue.Queue, @@ -49,6 +51,7 @@ const fakeServer = ( const line = yield* Queue.take(output); const message = VibeProtocol.parseJsonRpcLine(line); if (!message || !VibeProtocol.isJsonRpcRequest(message)) return; + seenRequests.push({ method: message.method, params: message.params }); switch (message.method) { case "initialize": return yield* serverLine(input, { @@ -127,6 +130,25 @@ describe("VibeAppServerClient over in-memory stdio", () => { yield* Effect.yieldNow; expect(notifications.some((n) => n.method === "turn/started")).toBe(true); + // Pagination goes under `page`, and callback answers nest `{ callbackId, output }` in `result`. + yield* client + .sessionHistoryList({ sessionId: "s1", limit: 5, cursor: "c1" }) + .pipe(Effect.ignore); + yield* client.sessionTurnsList({ sessionId: "s1", limit: 5 }).pipe(Effect.ignore); + yield* client + .callbackResult({ sessionId: "s1", callbackId: "cb1", result: { outcome: "approved" } }) + .pipe(Effect.ignore); + yield* Effect.yieldNow; + const historyReq = seenRequests.find((r) => r.method === "session/history/list"); + expect(historyReq?.params).toEqual({ sessionId: "s1", page: { limit: 5, cursor: "c1" } }); + const turnsReq = seenRequests.find((r) => r.method === "session/turns/list"); + expect(turnsReq?.params).toEqual({ sessionId: "s1", page: { limit: 5 } }); + const callbackReq = seenRequests.find((r) => r.method === "callback/result"); + expect(callbackReq?.params).toEqual({ + sessionId: "s1", + result: { callbackId: "cb1", output: { outcome: "approved" } }, + }); + yield* Fiber.interrupt(serverFiber); }), ), diff --git a/packages/vibe-app-server/src/client.ts b/packages/vibe-app-server/src/client.ts index 833fca221a1f1..0dca5e11c8bd1 100644 --- a/packages/vibe-app-server/src/client.ts +++ b/packages/vibe-app-server/src/client.ts @@ -496,18 +496,21 @@ export const make = Effect.fn("@t3tools/vibe-app-server/VibeAppServerClient.make }); }), sessionHistoryList: Effect.fn("VibeAppServerClient.sessionHistoryList")(function* (input) { + const page: Record = { limit: input.limit ?? 200 }; + if (input.cursor !== undefined && input.cursor !== null) page["cursor"] = input.cursor; return yield* typed("session/history/list", PageResult, { sessionId: input.sessionId, - ...(input.cursor !== undefined ? { cursor: input.cursor } : {}), - ...(input.limit !== undefined ? { limit: input.limit } : {}), + page, }); }), sessionTurnsList: Effect.fn("VibeAppServerClient.sessionTurnsList")(function* (input) { - const params: Record = { sessionId: input.sessionId }; - if (input.cursor !== undefined && input.cursor !== null) params["cursor"] = input.cursor; - if (input.limit !== undefined) params["limit"] = input.limit; - const page = yield* typed("session/turns/list", PageResult, params); - return yield* Effect.forEach(page.items, (item) => + const page: Record = { limit: input.limit ?? 200 }; + if (input.cursor !== undefined && input.cursor !== null) page["cursor"] = input.cursor; + const pageResult = yield* typed("session/turns/list", PageResult, { + sessionId: input.sessionId, + page, + }); + return yield* Effect.forEach(pageResult.items, (item) => decodeTurnItem(item).pipe( Effect.mapError( (error) => @@ -560,8 +563,10 @@ export const make = Effect.fn("@t3tools/vibe-app-server/VibeAppServerClient.make callbackResult: Effect.fn("VibeAppServerClient.callbackResult")(function* (input) { return yield* requestRaw("callback/result", { sessionId: input.sessionId, - callbackId: input.callbackId, - result: input.result, + result: { + callbackId: input.callbackId, + output: input.result, + }, }); }), }); diff --git a/packages/vibe-app-server/src/protocol.ts b/packages/vibe-app-server/src/protocol.ts index 5d5de041cc5f8..58aa6188e23d9 100644 --- a/packages/vibe-app-server/src/protocol.ts +++ b/packages/vibe-app-server/src/protocol.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; @@ -125,8 +126,8 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro string | number, Deferred.Deferred >(); - const requestQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); + const requestPubSub = yield* PubSub.unbounded(); + const notificationPubSub = yield* PubSub.unbounded(); const outgoing = yield* Queue.unbounded(); const termination = yield* Deferred.make(); let nextId = 1; @@ -162,8 +163,23 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro const failTermination = (error: VibeError.VibeAppServerError) => Effect.andThen( - Deferred.fail(termination, error), - options.onTermination ? options.onTermination(error) : Effect.void, + Effect.forEach( + [...pending.entries()], + ([id, deferred]) => + Deferred.fail( + deferred, + new VibeError.VibeAppServerRequestError({ + method: "", + code: "terminated", + detail: error.message, + }), + ).pipe(Effect.asVoid, Effect.andThen(Effect.sync(() => pending.delete(id)))), + { discard: true }, + ), + Effect.andThen( + Deferred.fail(termination, error), + options.onTermination ? options.onTermination(error) : Effect.void, + ), ); const dispatch = ( @@ -208,7 +224,7 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro ); return Effect.asVoid( Effect.andThen( - Queue.offer(requestQueue, message), + Effect.asVoid(PubSub.publish(requestPubSub, message)), Effect.flatMap(answer, (response) => "result" in response ? enqueueOutgoing({ jsonrpc: "2.0", id: message.id, result: response.result }) @@ -220,7 +236,7 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro if (isJsonRpcNotification(message)) { return Effect.asVoid( Effect.andThen( - Queue.offer(notificationQueue, message), + Effect.asVoid(PubSub.publish(notificationPubSub, message)), options.onNotification === undefined ? Effect.void : Effect.ignore(options.onNotification(message)), @@ -230,12 +246,13 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro return Effect.void; }; + // Partial lines survive chunk boundaries: remainder lives outside the per-chunk callback. + let remainder = ""; yield* options.stdio.stdin.pipe( Stream.decodeText(), Stream.runForEach( Effect.fnUntraced(function* (chunk) { let start = 0; - let remainder = ""; while (start < chunk.length) { const newline = chunk.indexOf("\n", start); const fragment = chunk.slice(start, newline === -1 ? chunk.length : newline); @@ -299,16 +316,33 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro method, ...(params !== undefined ? { params } : {}), }); - return yield* Effect.mapError(Deferred.await(deferred), (error) => - error.method.length === 0 - ? new VibeError.VibeAppServerRequestError({ - method, - code: error.code, - detail: error.detail, - ...(error.data !== undefined ? { data: error.data } : {}), - }) - : error, + // Race the response against termination so a dead server cannot suspend callers forever. + const result = yield* Effect.mapError( + Effect.raceFirst( + Deferred.await(deferred), + Deferred.await(termination).pipe( + Effect.mapError( + (error) => + new VibeError.VibeAppServerRequestError({ + method, + code: "terminated", + detail: error.message, + }), + ), + ), + ), + (error) => + error.method.length === 0 + ? new VibeError.VibeAppServerRequestError({ + method, + code: error.code, + detail: error.detail, + ...(error.data !== undefined ? { data: error.data } : {}), + }) + : error, ); + pending.delete(id); + return result; }); const notify = (method: string, params?: unknown) => @@ -338,8 +372,8 @@ export const makeVibeAppServerProtocol = Effect.fn("@t3tools/vibe-app-server/pro notify, respond, respondError, - incomingRequests: Stream.fromQueue(requestQueue), - incomingNotifications: Stream.fromQueue(notificationQueue), + incomingRequests: Stream.fromPubSub(requestPubSub), + incomingNotifications: Stream.fromPubSub(notificationPubSub), awaitTermination: Deferred.await(termination), }; }, From f372acc6e247a811b920cdd2b919af712efc1baa Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 10:08:55 +0100 Subject: [PATCH 03/18] fix(provider-vibe): resolve coderabbit follow-ups on #31881 - name vibe-app-server (not vibe-acp) in the MISTRAL_API_KEY setting text - protocolError uses a fixed detail; raw server text stays in cause - repeated turn/started no longer bumps the ordinal - echoed user history entries are not re-projected as duplicate messages - failed model writes log a warning instead of being silently ignored - rollback resolves the target turn's history entry id for session/rewind and propagates rewind errors as rollback failures - text generation scopes the app-server client per request - child stderr is drained in a scoped fiber so a chatty server cannot block - tests: deterministic in-memory flush instead of real-time waits Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/client.ts | 2 +- packages/provider-vibe/src/server/adapter.ts | 60 ++++++++++++++++--- .../src/server/textGeneration.ts | 2 + packages/vibe-app-server/src/client.test.ts | 10 +++- packages/vibe-app-server/src/client.ts | 2 + 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/provider-vibe/src/client.ts b/packages/provider-vibe/src/client.ts index d86b100ceea75..ab3cec108cb28 100644 --- a/packages/provider-vibe/src/client.ts +++ b/packages/provider-vibe/src/client.ts @@ -15,7 +15,7 @@ export const vibeClient = defineProviderClient({ name: "MISTRAL_API_KEY", label: "Mistral API key", description: - "Optional. La Plateforme API key used by vibe-acp instead of an interactive login.", + "Optional. La Plateforme API key used by vibe-app-server instead of an interactive login.", placeholder: "Paste API key", sensitive: true, }, diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index be803916808ef..b92cbc4bca49a 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -256,10 +256,11 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const host = yield* ProviderHost.ProviderHost; const random = yield* SecureRandom.SecureRandom; + // Fixed detail only: raw server text stays in `cause` and must not reach the UI. const protocolError = (cause: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ driver: VIBE_PROVIDER, - detail: cause instanceof Error ? cause.message : String(cause), + detail: "Mistral Vibe app-server request failed.", cause, }); @@ -307,6 +308,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( ) { if (!state.thread) return; const role = str(entry["role"]); + // The enqueued user input is echoed back as a history entry; the app already owns that + // message, so re-projecting it would duplicate it. Only agent entries become messages. + if (role === "user") return; const text = entryText(entry); if (!text) return; const messageId = idAllocator.derive.messageFromProviderItem({ @@ -420,7 +424,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const turn = asRecord(record["turn"]); const nativeTurnId = str(turn?.["id"]); if (!nativeTurnId || !state.thread) return; - const ordinal = (state.turnOrdinals.get(nativeTurnId) ?? state.turnOrdinals.size) + 1; + const ordinal = state.turnOrdinals.get(nativeTurnId) ?? state.turnOrdinals.size + 1; state.turnOrdinals.set(nativeTurnId, ordinal); const providerTurnId = idAllocator.derive.providerTurn({ driver: VIBE_PROVIDER, @@ -682,6 +686,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( runAttemptId: turnInput.attemptId, rootNodeId: turnInput.rootNodeId, }; + // A rejected model write must not silently run the turn with the previous model. yield* Effect.mapError( client.configModelWrite({ sessionId: nativeSessionId, @@ -690,7 +695,16 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( sessionOnly: true, }), protocolError, - ).pipe(Effect.ignore); + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "Mistral Vibe rejected the model write; the turn keeps the session model.", + { + cause, + }, + ), + ), + ); yield* Effect.mapError( client.sessionTurnEnqueue({ sessionId: nativeSessionId, @@ -871,13 +885,41 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( checkpointId: rollbackInput.target.checkpointId, }); } - const entryId = rollbackInput.target.providerTurn.nativeTurnRef?.nativeId; - if (entryId) { - yield* Effect.mapError( - client.request("session/rewind", { sessionId: nativeSessionId, entryId }), - protocolError, - ).pipe(Effect.ignore); + // session/rewind takes a history entry id, not a turn id: resolve the input entry of the target turn. + const targetTurnId = rollbackInput.target.providerTurn.nativeTurnRef?.nativeId; + if (!targetTurnId) { + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + }); + } + const history = yield* Effect.mapError( + client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500 }), + protocolError, + ); + const entryId = history.items + .map((item) => asRecord(item)) + .find( + (entry) => str(entry?.["turnId"]) === targetTurnId && str(entry?.["role"]) === "user", + )?.["id"]; + if (typeof entryId !== "string" || entryId.length === 0) { + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + }); } + yield* Effect.mapError( + client.request("session/rewind", { sessionId: nativeSessionId, entryId }), + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ); return yield* readThreadSnapshot({ providerThread: rollbackInput.providerThread }); }); diff --git a/packages/provider-vibe/src/server/textGeneration.ts b/packages/provider-vibe/src/server/textGeneration.ts index 67a64afcee2ee..3e5782d2b2abc 100644 --- a/packages/provider-vibe/src/server/textGeneration.ts +++ b/packages/provider-vibe/src/server/textGeneration.ts @@ -103,6 +103,8 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi return reply ?? ""; }), ).pipe( + // Scope the client so the app-server process is killed when the request ends. + Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.timeoutOption(VIBE_TEXT_GEN_TIMEOUT_MS), Effect.flatMap((option) => diff --git a/packages/vibe-app-server/src/client.test.ts b/packages/vibe-app-server/src/client.test.ts index ac9d2b8e6af36..2ef6f651b3a61 100644 --- a/packages/vibe-app-server/src/client.test.ts +++ b/packages/vibe-app-server/src/client.test.ts @@ -35,6 +35,12 @@ describe("parseJsonRpcLine", () => { }); }); +/* Let queued fibers run until the in-memory pipes are drained. */ +const flushQueues = () => + Effect.gen(function* () { + for (let i = 0; i < 10; i++) yield* Effect.yieldNow; + }); + const encoder = new TextEncoder(); const serverLine = (input: Queue.Queue, payload: unknown) => Queue.offer(input, encoder.encode(`${JSON.stringify(payload)}\n`)); @@ -127,7 +133,7 @@ describe("VibeAppServerClient over in-memory stdio", () => { // Unknown method triggers the notification path on the fake server. yield* client.sessionDelete("s1"); - yield* Effect.yieldNow; + yield* flushQueues(); expect(notifications.some((n) => n.method === "turn/started")).toBe(true); // Pagination goes under `page`, and callback answers nest `{ callbackId, output }` in `result`. @@ -138,7 +144,7 @@ describe("VibeAppServerClient over in-memory stdio", () => { yield* client .callbackResult({ sessionId: "s1", callbackId: "cb1", result: { outcome: "approved" } }) .pipe(Effect.ignore); - yield* Effect.yieldNow; + yield* flushQueues(); const historyReq = seenRequests.find((r) => r.method === "session/history/list"); expect(historyReq?.params).toEqual({ sessionId: "s1", page: { limit: 5, cursor: "c1" } }); const turnsReq = seenRequests.find((r) => r.method === "session/turns/list"); diff --git a/packages/vibe-app-server/src/client.ts b/packages/vibe-app-server/src/client.ts index 0dca5e11c8bd1..9edaed5fc57cc 100644 --- a/packages/vibe-app-server/src/client.ts +++ b/packages/vibe-app-server/src/client.ts @@ -582,6 +582,8 @@ const makeChildProcessClient = Effect.fn( "@t3tools/vibe-app-server/VibeAppServerClient.makeChildProcessClient", )(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: VibeAppServerClientOptions) { const encoder = new TextEncoder(); + // Drain the child's stderr in a scoped fiber, or a chatty server can block on a full pipe. + yield* Effect.forkScoped(handle.stderr.pipe(Stream.runDrain)); const stdio = Stdio.make({ args: Effect.succeed([]), stdin: handle.stdout, From 3a9c2eba7432072ffa80e2542f4543175814539a Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 10:25:12 +0100 Subject: [PATCH 04/18] fix(provider-vibe): resolve latest review batch on #31881 - a cancelled turn now reports terminal status cancelled instead of failed - user_input callbacks keep kind user_input end to end (pending + resolved) - advertised client tools now have handlers: clientTool/read|writeTextFile served from the session checkout; terminal tool no longer advertised - probe/text-generation clients accept callback delivery and auto-decline, and no longer advertise client tools they cannot serve - probe failure message is fixed text; the cause goes to logs only Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 177 ++++++++++++------ .../src/server/appServerSupport.ts | 34 +++- packages/provider-vibe/src/server/driver.ts | 1 + packages/provider-vibe/src/server/status.ts | 4 +- 4 files changed, 156 insertions(+), 60 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index b92cbc4bca49a..f2064ef201b54 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -18,6 +18,7 @@ import type { VibeSettings } from "../settings.ts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -246,7 +247,10 @@ interface AdapterState { /** History entries by native id, so `history/entryUpdated` patches can be applied. */ entries: Map>; /** Maps a T3 runtime-request id to the native callback id answered via `callback/result`. */ - callbackByRequest: Map; + callbackByRequest: Map< + RuntimeRequestId, + { readonly callbackId: string; readonly kind: "permission" | "user_input" } + >; } export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( @@ -265,6 +269,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; const openSessionInner = Effect.fn("VibeAdapterV2.openSession")(function* ( input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, @@ -289,7 +294,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( clientVersion: "0.0.0", capabilities: { callbackKinds: ["approval", "user_input"], - clientTools: ["filesystem/read", "filesystem/write", "terminal"], + clientTools: ["filesystem/read", "filesystem/write"], }, }), protocolError, @@ -462,12 +467,15 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const { providerTurnId } = state.activeTurn; const completedAt = yield* DateTime.now; const status = str(turn?.["status"]) ?? method.replace("turn/", ""); + // A user-cancelled turn is its own terminal status, not a failure. const terminalStatus = - status === "failed" || status === "cancelled" + status === "failed" ? "failed" - : status === "interrupted" - ? "interrupted" - : "completed"; + : status === "cancelled" + ? "cancelled" + : status === "interrupted" + ? "interrupted" + : "completed"; yield* emit({ type: "provider_turn.updated", driver: VIBE_PROVIDER, @@ -498,7 +506,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( providerThreadId: state.thread.id, providerTurnId, runOrdinal: Math.max(1, state.lastRunOrdinal), - status: terminalStatus === "failed" ? "cancelled" : terminalStatus, + status: terminalStatus as "cancelled" | "completed" | "interrupted", failure: null, threadDisposition: "reusable", }); @@ -542,56 +550,105 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( // Approvals and structured questions arrive as the `callback/call` server request: // surface them as a live runtime request, ack the call, and answer later via `callback/result`. - yield* client.handleUnknownServerRequest( - Effect.fn("VibeAdapterV2.onServerRequest")(function* (method: string, params: unknown) { - const toWireError = (cause: unknown) => - new VibeAppServerError.VibeAppServerRequestError({ - method, - code: "internal_error", - detail: cause instanceof Error ? cause.message : String(cause), - }); - if (method !== "callback/call") { + const fileSystem = yield* FileSystem.FileSystem; + const onServerRequest = Effect.fn("VibeAdapterV2.onServerRequest")(function* ( + method: string, + params: unknown, + ) { + const toWireError = (cause: unknown) => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "internal_error", + detail: cause instanceof Error ? cause.message : String(cause), + }); + // Advertised client tools get matching handlers: serve file read/write from the session checkout. + if (method === "clientTool/readTextFile" || method === "clientTool/writeTextFile") { + const record = asRecord(params) ?? {}; + const filePath = str(record["path"]); + if (!filePath) { return yield* new VibeAppServerError.VibeAppServerRequestError({ method, - code: "method_not_found", - detail: `Unsupported vibe-app-server request: ${method}`, + code: "invalid_params", + detail: "path is required", }); } - const callback = asRecord(asRecord(params)?.["callback"]); - const callbackId = str(callback?.["callbackId"]) ?? str(callback?.["callback_id"]); - if (!callbackId) { - return { accepted: false }; + const absolute = filePath.startsWith("/") ? filePath : `${cwd}/${filePath}`; + const fileSystem = yield* FileSystem.FileSystem; + if (method === "clientTool/readTextFile") { + const content = yield* fileSystem.readFileString(absolute).pipe( + Effect.mapError( + (cause) => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "internal_error", + detail: "read failed", + data: cause, + }), + ), + ); + return { content }; } - const requestId = yield* idAllocator.allocate - .runtimeRequest({ - driver: VIBE_PROVIDER, - ...(state.activeTurn !== undefined - ? { providerTurnId: state.activeTurn.providerTurnId } - : {}), - nativeRequestId: callbackId, - }) - .pipe(Effect.mapError(toWireError)); - state.callbackByRequest.set(requestId, callbackId); - const createdAt = yield* DateTime.now; - const kind = str(callback?.["kind"]) ?? "permission"; - yield* emit({ - type: "runtime_request.updated", - driver: VIBE_PROVIDER, - threadId: input.threadId, - runtimeRequest: { - id: requestId, - nodeId: state.currentRun?.rootNodeId ?? NodeId.make(`vibe-request-${requestId}`), - providerTurnId: state.activeTurn?.providerTurnId ?? null, - nativeRequestRef: nativeRef(callbackId), - kind: kind === "user_input" ? "permission" : "permission", - status: "pending", - responseCapability: { type: "live", providerSessionId }, - createdAt, - resolvedAt: null, - } satisfies OrchestrationV2RuntimeRequest, + const content = typeof record["content"] === "string" ? record["content"] : ""; + yield* fileSystem.writeFileString(absolute, content).pipe( + Effect.mapError( + (cause) => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "internal_error", + detail: "write failed", + data: cause, + }), + ), + ); + return {}; + } + if (method !== "callback/call") { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "method_not_found", + detail: `Unsupported vibe-app-server request: ${method}`, }); - return { callbackId, accepted: true }; - }), + } + const callback = asRecord(asRecord(params)?.["callback"]); + const callbackId = str(callback?.["callbackId"]) ?? str(callback?.["callback_id"]); + if (!callbackId) { + return { accepted: false }; + } + const requestId = yield* idAllocator.allocate + .runtimeRequest({ + driver: VIBE_PROVIDER, + ...(state.activeTurn !== undefined + ? { providerTurnId: state.activeTurn.providerTurnId } + : {}), + nativeRequestId: callbackId, + }) + .pipe(Effect.mapError(toWireError)); + const kind = + str(callback?.["kind"]) === "user_input" + ? ("user_input" as const) + : ("permission" as const); + state.callbackByRequest.set(requestId, { callbackId, kind }); + const createdAt = yield* DateTime.now; + yield* emit({ + type: "runtime_request.updated", + driver: VIBE_PROVIDER, + threadId: input.threadId, + runtimeRequest: { + id: requestId, + nodeId: state.currentRun?.rootNodeId ?? NodeId.make(`vibe-request-${requestId}`), + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeRequestRef: nativeRef(callbackId), + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId }, + createdAt, + resolvedAt: null, + } satisfies OrchestrationV2RuntimeRequest, + }); + return { callbackId, accepted: true }; + }); + yield* client.handleUnknownServerRequest((method, params) => + Effect.provideService(onServerRequest(method, params), FileSystem.FileSystem, fileSystem), ); const ensureThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["ensureThread"] = Effect.fn( @@ -761,8 +818,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); } const decision = responseInput.decision ?? "cancel"; - const callbackId = - state.callbackByRequest.get(responseInput.requestId) ?? responseInput.requestId; + const pending = state.callbackByRequest.get(responseInput.requestId); + const callbackId = pending?.callbackId ?? responseInput.requestId; + const requestKind = pending?.kind ?? "permission"; const result = decision === "cancel" ? { outcome: "cancelled" as const } @@ -792,7 +850,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( NodeId.make(`vibe-request-${responseInput.requestId}`), providerTurnId: state.activeTurn?.providerTurnId ?? null, nativeRequestRef: nativeRef(callbackId), - kind: "permission", + kind: requestKind, status: "resolved", responseCapability: { type: "live", providerSessionId }, createdAt: resolvedAt, @@ -1015,9 +1073,13 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }), openSession: Effect.fn("VibeAdapterV2.openSession")(function* (input) { return yield* Effect.provideService( - openSessionInner(input), - ChildProcessSpawner.ChildProcessSpawner, - spawner, + Effect.provideService( + openSessionInner(input), + ChildProcessSpawner.ChildProcessSpawner, + spawner, + ), + FileSystem.FileSystem, + fileSystem, ); }), }); @@ -1025,6 +1087,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( export type VibeAdapterV2Env = | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem | SecureRandom.SecureRandom | IdAllocator.IdAllocatorV2 | ProviderHost.ProviderHost; diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index db589554113e3..77cdaf85154a0 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -16,6 +16,14 @@ import * as VibeError from "@t3tools/vibe-app-server/errors"; export const VIBE_PROVIDER = ProviderDriverKind.make("vibe"); export const VIBE_DEFAULT_BINARY = "vibe-app-server"; + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const str = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; export const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; /** Mistral Vibe's application rate-limit code, outside the JSON-RPC reserved range. */ @@ -167,12 +175,36 @@ export const withVibeAppServerClient = Effect.fn("VibeAppServerSupport.withVibeA const init = yield* client.initialize({ clientName: input.clientInfo?.name ?? "t3-code", clientVersion: input.clientInfo?.version ?? "0.0.0", + // Non-interactive contexts (probe, text generation) cannot answer prompts: advertise + // callbacks so delivery is accepted, then resolve them immediately as declined. capabilities: { callbackKinds: ["approval", "user_input"], - clientTools: ["filesystem/read", "filesystem/write", "terminal"], }, }); yield* client.initialized(); + yield* client.handleUnknownServerRequest((method, params) => { + if (method !== "callback/call") { + return Effect.fail( + new VibeError.VibeAppServerRequestError({ + method, + code: "method_not_found", + detail: `Unsupported vibe-app-server request: ${method}`, + }), + ); + } + const callback = asRecord(asRecord(params)?.["callback"]); + const callbackId = str(callback?.["callbackId"]) ?? str(callback?.["callback_id"]); + const sessionId = str(callback?.["sessionId"]); + if (!callbackId || !sessionId) return Effect.succeed({ accepted: false }); + return Effect.as( + client.callbackResult({ + sessionId, + callbackId, + result: { error: "not answerable in this context" }, + }), + { callbackId, accepted: true }, + ); + }); return yield* use(client, init.serverInfo).pipe( Effect.ensuring(child.kill().pipe(Effect.ignore)), ); diff --git a/packages/provider-vibe/src/server/driver.ts b/packages/provider-vibe/src/server/driver.ts index 06bd5aa1435a7..cf283ba9bbd3d 100644 --- a/packages/provider-vibe/src/server/driver.ts +++ b/packages/provider-vibe/src/server/driver.ts @@ -108,6 +108,7 @@ export const VibeDriver: ProviderDriver = { }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(SecureRandom.SecureRandom, random), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/packages/provider-vibe/src/server/status.ts b/packages/provider-vibe/src/server/status.ts index bbfeeabf878b4..6200b179ff47e 100644 --- a/packages/provider-vibe/src/server/status.ts +++ b/packages/provider-vibe/src/server/status.ts @@ -212,14 +212,14 @@ const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* : option.value, ), Effect.catchCause((cause) => - Effect.succeed({ + Effect.as(Effect.logWarning("Mistral Vibe app server probe failed", { cause }), { version: null, auth: { status: "unknown" }, usageLimits: undefined, models: [...FALLBACK_MODELS], defaultAgent: undefined, installed: false, - message: `Mistral Vibe app server probe failed: ${cause.toString().slice(0, 300)}`, + message: "Mistral Vibe app server probe failed.", } satisfies VibeProbeResult), ), ); From 46938b9399007ab62133bf8a5552885782c7c13e Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 10:29:58 +0100 Subject: [PATCH 05/18] fix(provider-vibe): resolve latest review batch on #31881 - rollback now follows history pagination (nextCursor) until the target turn's input entry is found, instead of only scanning the first page - app-server child processes get forceKillAfter on spawn and explicit kill so a SIGTERM-ignoring server cannot block scope close or the timeout Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 30 +++++++++++++------ .../src/server/appServerSupport.ts | 4 ++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index f2064ef201b54..79ce3833595cd 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -279,6 +279,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const child = yield* spawner .spawn( ChildProcess.make(spawn.command, [...spawn.args], { + forceKillAfter: "1 second", ...(spawn.cwd !== undefined ? { cwd: spawn.cwd } : {}), ...(spawn.env !== undefined ? { env: spawn.env } : {}), }), @@ -952,15 +953,26 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( checkpointId: rollbackInput.target.checkpointId, }); } - const history = yield* Effect.mapError( - client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500 }), - protocolError, - ); - const entryId = history.items - .map((item) => asRecord(item)) - .find( - (entry) => str(entry?.["turnId"]) === targetTurnId && str(entry?.["role"]) === "user", - )?.["id"]; + // session/rewind takes a history entry id, not a turn id: resolve the input entry of the + // target turn, following nextCursor across all pages so older targets are found too. + let entryId: string | undefined; + let cursor: string | null | undefined = null; + while (entryId === undefined) { + const page: { + readonly items: ReadonlyArray; + readonly nextCursor?: string | null | undefined; + } = yield* Effect.mapError( + client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500, cursor }), + protocolError, + ); + entryId = page.items + .map((item) => asRecord(item)) + .find( + (entry) => str(entry?.["turnId"]) === targetTurnId && str(entry?.["role"]) === "user", + )?.["id"] as string | undefined; + cursor = page.nextCursor ?? null; + if (cursor === null) break; + } if (typeof entryId !== "string" || entryId.length === 0) { return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: VIBE_PROVIDER, diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index 77cdaf85154a0..e878c80ea7260 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -150,6 +150,8 @@ export const withVibeAppServerClient = Effect.fn("VibeAppServerSupport.withVibeA const child = yield* spawner .spawn( ChildProcess.make(spawn.command, [...spawn.args], { + // A server that ignores SIGTERM must not block scope close (and the request timeout). + forceKillAfter: "1 second", ...(spawn.cwd !== undefined ? { cwd: spawn.cwd } : {}), ...(spawn.env !== undefined ? { env: spawn.env } : {}), }), @@ -206,7 +208,7 @@ export const withVibeAppServerClient = Effect.fn("VibeAppServerSupport.withVibeA ); }); return yield* use(client, init.serverInfo).pipe( - Effect.ensuring(child.kill().pipe(Effect.ignore)), + Effect.ensuring(child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore)), ); }, ); From d3340a6cb4226201d2426ca4a31d6225de9ba909 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 10:36:05 +0100 Subject: [PATCH 06/18] fix(provider-vibe): resolve latest adapter review items on #31881 - toWireError uses a fixed detail with the cause in data, never cause text - clientTool/writeTextFile rejects a missing content with invalid_params instead of silently writing an empty file Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 79ce3833595cd..6bdc17819b103 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -560,7 +560,8 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( new VibeAppServerError.VibeAppServerRequestError({ method, code: "internal_error", - detail: cause instanceof Error ? cause.message : String(cause), + detail: "Failed to allocate runtime request.", + data: cause, }); // Advertised client tools get matching handlers: serve file read/write from the session checkout. if (method === "clientTool/readTextFile" || method === "clientTool/writeTextFile") { @@ -589,7 +590,14 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( ); return { content }; } - const content = typeof record["content"] === "string" ? record["content"] : ""; + const content = record["content"]; + if (typeof content !== "string") { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "invalid_params", + detail: "content is required", + }); + } yield* fileSystem.writeFileString(absolute, content).pipe( Effect.mapError( (cause) => From a5d93549099661d0b6703004994a062e5deb0cc1 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 11:02:59 +0100 Subject: [PATCH 07/18] fix(provider-vibe): confine clientTool paths, full-history reads, callback envelopes on #31881 - Confine clientTool/readTextFile|writeTextFile to the session checkout (reject .. and absolute escapes with invalid_params) - Read all history pages via nextCursor in readThreadSnapshot and rollbackThread so long threads and old rewind targets are found - Deliver vibe-app-server CallbackOutput envelopes on callback/result: approval decision vs user_input answers, no silent cancel default Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 139 +++++++++++++------ packages/provider-vibe/src/server/driver.ts | 1 + 2 files changed, 95 insertions(+), 45 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 6bdc17819b103..03c982ee88294 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -20,6 +20,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -270,6 +271,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const openSessionInner = Effect.fn("VibeAdapterV2.openSession")(function* ( input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, @@ -574,7 +576,18 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( detail: "path is required", }); } - const absolute = filePath.startsWith("/") ? filePath : `${cwd}/${filePath}`; + // Confine reads/writes to the session checkout: resolve and reject paths outside it. + const path = yield* Path.Path; + const root = path.resolve(cwd); + const absolute = path.resolve(root, filePath); + const relative = path.relative(root, absolute); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "invalid_params", + detail: "path is outside the workspace", + }); + } const fileSystem = yield* FileSystem.FileSystem; if (method === "clientTool/readTextFile") { const content = yield* fileSystem.readFileString(absolute).pipe( @@ -657,7 +670,11 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( return { callbackId, accepted: true }; }); yield* client.handleUnknownServerRequest((method, params) => - Effect.provideService(onServerRequest(method, params), FileSystem.FileSystem, fileSystem), + Effect.provideService( + Effect.provideService(onServerRequest(method, params), FileSystem.FileSystem, fileSystem), + Path.Path, + path, + ), ); const ensureThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["ensureThread"] = Effect.fn( @@ -826,24 +843,45 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( detail: "No Mistral Vibe session owns this runtime request.", }); } - const decision = responseInput.decision ?? "cancel"; const pending = state.callbackByRequest.get(responseInput.requestId); const callbackId = pending?.callbackId ?? responseInput.requestId; const requestKind = pending?.kind ?? "permission"; - const result = - decision === "cancel" - ? { outcome: "cancelled" as const } - : decision === "decline" - ? { outcome: "denied" as const } - : { - outcome: "approved" as const, - ...(responseInput.answers ? { answers: responseInput.answers } : {}), - }; + // An answer-only response (no decision) on a user_input callback must still deliver the + // answers; defaulting to cancel here would discard them. + const decision = + responseInput.decision ?? (requestKind === "user_input" ? undefined : "cancel"); + // vibe-app-server CallbackOutput contract: approvals and user input have distinct envelopes. + const output = + requestKind === "user_input" + ? { + type: "user_input" as const, + result: { + answers: Object.entries(responseInput.answers ?? {}).map( + ([question, answer]) => ({ question, answer: String(answer) }), + ), + cancelled: decision === "cancel", + }, + } + : { + type: "approval" as const, + decision: { + type: (decision === "accept" + ? "approve" + : decision === "acceptForSession" + ? "approve_for_session" + : decision === "acceptAlways" + ? "approve_permanently" + : decision === "decline" + ? "deny" + : "cancel_turn") as string, + }, + ...(responseInput.feedback ? { feedback: responseInput.feedback } : {}), + }; yield* Effect.mapError( client.callbackResult({ sessionId: nativeSessionId, callbackId, - result, + result: output, }), protocolError, ); @@ -870,6 +908,30 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); }); + // Reads every history page (following nextCursor), so long threads are not truncated. + const readAllHistoryEntries = Effect.fn("VibeAdapterV2.readAllHistoryEntries")(function* ( + nativeSessionId: string, + ) { + const entries: Array> = []; + let cursor: string | null | undefined = null; + while (true) { + const page: { + readonly items: ReadonlyArray; + readonly nextCursor?: string | null | undefined; + } = yield* Effect.mapError( + client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500, cursor }), + protocolError, + ); + for (const item of page.items) { + const entry = asRecord(item); + if (entry) entries.push(entry); + } + cursor = page.nextCursor ?? null; + if (cursor === null) break; + } + return entries; + }); + const readThreadSnapshot: ProviderAdapter.ProviderAdapterV2SessionRuntime["readThreadSnapshot"] = Effect.fn("VibeAdapterV2.readThreadSnapshot")(function* (snapshotInput) { const nativeSessionId = snapshotInput.providerThread.nativeThreadRef?.nativeId; @@ -879,20 +941,16 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( providerThreadId: snapshotInput.providerThread.id, }); } - const history = yield* Effect.mapError( - client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500 }), - protocolError, - ); + const historyEntries = yield* readAllHistoryEntries(nativeSessionId); const turns = yield* Effect.mapError( - client.sessionTurnsList({ sessionId: nativeSessionId, limit: 200 }), + client.sessionTurnsList({ sessionId: nativeSessionId, limit: 500 }), protocolError, ); const now = yield* DateTime.now; const messages: OrchestrationV2ConversationMessage[] = []; - for (const item of history.items) { - const entry = asRecord(item); + for (const entry of historyEntries) { const text = entryText(entry); - if (!entry || !text) continue; + if (!text) continue; const role = str(entry["role"]) === "user" ? "user" : "assistant"; const nativeItemId = str(entry["id"]) ?? `entry-${messages.length}`; messages.push({ @@ -962,25 +1020,11 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); } // session/rewind takes a history entry id, not a turn id: resolve the input entry of the - // target turn, following nextCursor across all pages so older targets are found too. - let entryId: string | undefined; - let cursor: string | null | undefined = null; - while (entryId === undefined) { - const page: { - readonly items: ReadonlyArray; - readonly nextCursor?: string | null | undefined; - } = yield* Effect.mapError( - client.sessionHistoryList({ sessionId: nativeSessionId, limit: 500, cursor }), - protocolError, - ); - entryId = page.items - .map((item) => asRecord(item)) - .find( - (entry) => str(entry?.["turnId"]) === targetTurnId && str(entry?.["role"]) === "user", - )?.["id"] as string | undefined; - cursor = page.nextCursor ?? null; - if (cursor === null) break; - } + // target turn across all history pages so older targets are found too. + const historyEntries = yield* readAllHistoryEntries(nativeSessionId); + const entryId = historyEntries.find( + (entry) => str(entry["turnId"]) === targetTurnId && str(entry["role"]) === "user", + )?.["id"] as string | undefined; if (typeof entryId !== "string" || entryId.length === 0) { return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: VIBE_PROVIDER, @@ -1094,12 +1138,16 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( openSession: Effect.fn("VibeAdapterV2.openSession")(function* (input) { return yield* Effect.provideService( Effect.provideService( - openSessionInner(input), - ChildProcessSpawner.ChildProcessSpawner, - spawner, + Effect.provideService( + openSessionInner(input), + ChildProcessSpawner.ChildProcessSpawner, + spawner, + ), + FileSystem.FileSystem, + fileSystem, ), - FileSystem.FileSystem, - fileSystem, + Path.Path, + path, ); }), }); @@ -1108,6 +1156,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( export type VibeAdapterV2Env = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + | Path.Path | SecureRandom.SecureRandom | IdAllocator.IdAllocatorV2 | ProviderHost.ProviderHost; diff --git a/packages/provider-vibe/src/server/driver.ts b/packages/provider-vibe/src/server/driver.ts index cf283ba9bbd3d..d91ea8b17acd7 100644 --- a/packages/provider-vibe/src/server/driver.ts +++ b/packages/provider-vibe/src/server/driver.ts @@ -109,6 +109,7 @@ export const VibeDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), Effect.mapError( (cause) => new ProviderDriverError({ From ed7a28b8fff3e4487c5e1279789cebb5a636e2ad Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 11:04:02 +0100 Subject: [PATCH 08/18] fix(provider-vibe): harden clientTool containment against symlink escapes on #31881 Resolve the real path of the workspace root and target (falling back to the lexical path for not-yet-existing targets) and reject any resolved path outside the checkout. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 03c982ee88294..fa9905b75c584 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -578,17 +578,34 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( } // Confine reads/writes to the session checkout: resolve and reject paths outside it. const path = yield* Path.Path; - const root = path.resolve(cwd); + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem + .realPath(cwd) + .pipe(Effect.orElseSucceed(() => path.resolve(cwd))); const absolute = path.resolve(root, filePath); - const relative = path.relative(root, absolute); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + const isInside = (candidate: string) => { + const relative = path.relative(root, candidate); + return !relative.startsWith("..") && !path.isAbsolute(relative); + }; + if (!isInside(absolute)) { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "invalid_params", + detail: "path is outside the workspace", + }); + } + // Re-check against the filesystem-resolved path so symlinks cannot bypass containment; + // targets that do not exist yet fall back to the lexically resolved path. + const resolved = yield* fileSystem + .realPath(absolute) + .pipe(Effect.orElseSucceed(() => absolute)); + if (resolved !== absolute && !isInside(resolved)) { return yield* new VibeAppServerError.VibeAppServerRequestError({ method, code: "invalid_params", detail: "path is outside the workspace", }); } - const fileSystem = yield* FileSystem.FileSystem; if (method === "clientTool/readTextFile") { const content = yield* fileSystem.readFileString(absolute).pipe( Effect.mapError( From 5b73442224f697d20e9612236f92b9eafb452458 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 11:22:11 +0100 Subject: [PATCH 09/18] fix(provider-vibe): canonicalize clientTool target before I/O on #31881 Canonicalize the parent directory, containment-check the canonical target, reject dangling symlinks at the final component via readLink, and perform readFileString/writeFileString through the canonical path so a symlinked ancestor or a swapped symlink cannot redirect I/O outside the session checkout. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 39 +++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index fa9905b75c584..305ec43fa99bf 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -579,6 +579,12 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( // Confine reads/writes to the session checkout: resolve and reject paths outside it. const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; + const outsideWorkspace = () => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "invalid_params", + detail: "path is outside the workspace", + }); const root = yield* fileSystem .realPath(cwd) .pipe(Effect.orElseSucceed(() => path.resolve(cwd))); @@ -588,26 +594,31 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( return !relative.startsWith("..") && !path.isAbsolute(relative); }; if (!isInside(absolute)) { - return yield* new VibeAppServerError.VibeAppServerRequestError({ - method, - code: "invalid_params", - detail: "path is outside the workspace", - }); + return yield* outsideWorkspace(); } - // Re-check against the filesystem-resolved path so symlinks cannot bypass containment; - // targets that do not exist yet fall back to the lexically resolved path. - const resolved = yield* fileSystem - .realPath(absolute) - .pipe(Effect.orElseSucceed(() => absolute)); - if (resolved !== absolute && !isInside(resolved)) { + // Canonicalize the parent directory so a symlinked ancestor cannot redirect the I/O + // outside the checkout, then contain-check the canonical target. The canonical path is + // what gets read/written, so the check and the I/O cannot diverge. + const parent = yield* fileSystem + .realPath(path.dirname(absolute)) + .pipe(Effect.orElseSucceed(() => path.dirname(absolute))); + const candidate = path.join(parent, path.basename(absolute)); + const resolvedOption = yield* Effect.option(fileSystem.realPath(candidate)); + const resolved = Option.getOrElse(resolvedOption, () => candidate); + // A dangling symlink at the final component resolves nowhere: reject it explicitly. + const linkOption = yield* Effect.option(fileSystem.readLink(candidate)); + if (Option.isSome(linkOption) && Option.isNone(resolvedOption)) { return yield* new VibeAppServerError.VibeAppServerRequestError({ method, code: "invalid_params", - detail: "path is outside the workspace", + detail: "path is a dangling symlink", }); } + if (!isInside(resolved)) { + return yield* outsideWorkspace(); + } if (method === "clientTool/readTextFile") { - const content = yield* fileSystem.readFileString(absolute).pipe( + const content = yield* fileSystem.readFileString(resolved).pipe( Effect.mapError( (cause) => new VibeAppServerError.VibeAppServerRequestError({ @@ -628,7 +639,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( detail: "content is required", }); } - yield* fileSystem.writeFileString(absolute, content).pipe( + yield* fileSystem.writeFileString(resolved, content).pipe( Effect.mapError( (cause) => new VibeAppServerError.VibeAppServerRequestError({ From c320d37be2d5decc23d91f1246f6d4dbe3113c78 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 12:35:06 +0100 Subject: [PATCH 10/18] fix(provider-vibe): concatenate streamed assistant text instead of replacing it Vibe streams assistant text through a non-standard `append` patch op on history/entryUpdated. The adapter treated it as replace, so only the last chunk of each reply survived. Also project only assistant message entries. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 53 ++++--------------- .../src/server/appServerSupport.test.ts | 29 ++++++++++ .../src/server/appServerSupport.ts | 47 ++++++++++++++++ 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 305ec43fa99bf..303123f3431fb 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -38,6 +38,7 @@ import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { MISTRAL_VIBE_RATE_LIMITED, + applyVibeHistoryPatch, VIBE_PROVIDER, buildVibeSpawnInput, resolveVibeModelId, @@ -174,38 +175,6 @@ function entryText(entry: unknown): string { .join(""); } -/** Minimal JSON Patch (RFC 6902 subset: add/replace/remove on `/`-separated paths). */ -function applyJsonPatch( - target: Record, - patch: ReadonlyArray, -): Record { - const result: Record = JSON.parse(JSON.stringify(target)) as Record< - string, - unknown - >; - for (const operation of patch) { - const op = asRecord(operation); - if (!op) continue; - const path = str(op["path"]) ?? ""; - const segments = path.split("/").filter((segment) => segment.length > 0); - if (segments.length === 0) continue; - let cursor: Record | unknown[] = result; - for (const segment of segments.slice(0, -1)) { - const next = (cursor as Record)[segment]; - if (typeof next !== "object" || next === null) break; - cursor = next as Record; - } - const key = segments[segments.length - 1]!; - if (typeof cursor !== "object" || cursor === null) continue; - if (op["op"] === "remove") { - delete (cursor as Record)[key]; - } else { - (cursor as Record)[key] = op["value"]; - } - } - return result; -} - /** Maps a vibe turn error/stop reason to the native failure contract; `-31001` is Vibe's rate limit. */ export function vibeTurnFailure(turn: { readonly status?: unknown; @@ -316,9 +285,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( ) { if (!state.thread) return; const role = str(entry["role"]); - // The enqueued user input is echoed back as a history entry; the app already owns that - // message, so re-projecting it would duplicate it. Only agent entries become messages. - if (role === "user") return; + // User input is echoed back as a history entry and the app already owns it; system + // entries are not conversation. Only assistant entries become messages. + if (str(entry["type"]) !== "message" || role !== "assistant") return; const text = entryText(entry); if (!text) return; const messageId = idAllocator.derive.messageFromProviderItem({ @@ -331,13 +300,13 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); const timestamp = yield* DateTime.now; const message: OrchestrationV2ConversationMessage = { - createdBy: role === "user" ? "user" : "agent", + createdBy: "agent", creationSource: "provider", id: messageId, threadId, runId: state.currentRun?.runId ?? null, nodeId: state.currentRun?.rootNodeId ?? null, - role: role === "user" ? "user" : "assistant", + role: "assistant", text, attachments: [], streaming: false, @@ -359,13 +328,11 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( startedAt: null, completedAt: timestamp, updatedAt: timestamp, - type: role === "user" ? ("user_message" as const) : ("assistant_message" as const), + type: "assistant_message" as const, messageId, text, textOmitted: true as const, - ...(role === "user" - ? { inputIntent: "turn_start" as const, attachments: [] as const } - : { streaming: false as const }), + streaming: false as const, }; yield* emit({ type: "message.updated", driver: VIBE_PROVIDER, message }); yield* emit({ @@ -543,7 +510,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const stored = entryId !== undefined ? state.entries.get(entryId) : undefined; if (!entryId || !stored || !state.thread) return; const patch = Array.isArray(record["patch"]) ? record["patch"] : []; - const patched = applyJsonPatch(stored, patch); + const patched = applyVibeHistoryPatch(stored, patch); state.entries.set(entryId, patched); yield* projectHistoryEntry(patched, entryId, input.threadId); return; @@ -982,7 +949,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const role = str(entry["role"]) === "user" ? "user" : "assistant"; const nativeItemId = str(entry["id"]) ?? `entry-${messages.length}`; messages.push({ - createdBy: role === "user" ? "user" : "agent", + createdBy: "agent", creationSource: "provider", id: idAllocator.derive.messageFromProviderItem({ driver: VIBE_PROVIDER, diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts index 453c4092fd4f6..4c61470e3bb58 100644 --- a/packages/provider-vibe/src/server/appServerSupport.test.ts +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; import { VibeSettings } from "../settings.ts"; import { DEFAULT_VIBE_MODEL, + applyVibeHistoryPatch, VIBE_DEFAULT_BINARY, buildVibeEnvironment, buildVibeSpawnInput, @@ -97,3 +98,31 @@ describe("vibeWorktreeInputForCwd", () => { }); }); }); + +describe("applyVibeHistoryPatch", () => { + it("concatenates streamed `append` chunks instead of replacing the text", () => { + let entry: Record = { content: [{ type: "text", text: "" }] }; + for (const chunk of ["Let me ", "check the ", "DB for the UUID."]) { + entry = applyVibeHistoryPatch(entry, [ + { op: "append", path: "/content/0/text", value: chunk }, + { op: "replace", path: "/updatedAt", value: 1 }, + ]); + } + expect(entry).toEqual({ + content: [{ type: "text", text: "Let me check the DB for the UUID." }], + updatedAt: 1, + }); + }); + + it("inserts into arrays for `add`, including the `-` end marker", () => { + const entry = applyVibeHistoryPatch({ content: [{ type: "text", text: "a" }] }, [ + { op: "add", path: "/content/-", value: { type: "text", text: "c" } }, + { op: "add", path: "/content/1", value: { type: "text", text: "b" } }, + ]); + expect(entry["content"]).toEqual([ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + { type: "text", text: "c" }, + ]); + }); +}); diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index e878c80ea7260..a50a4f6ff9f78 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -24,6 +24,53 @@ const asRecord = (value: unknown): Record | undefined => const str = (value: unknown): string | undefined => typeof value === "string" && value.length > 0 ? value : undefined; +/** + * Applies a `history/entryUpdated` patch: RFC 6902 add/replace/remove plus Vibe's non-standard + * `append`, which streams assistant text by concatenating onto an existing string. + */ +export function applyVibeHistoryPatch( + target: Record, + patch: ReadonlyArray, +): Record { + const result = structuredClone(target); + for (const operation of patch) { + const op = asRecord(operation); + const segments = (str(op?.["path"]) ?? "") + .split("/") + .slice(1) + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); + if (!op || segments.length === 0) continue; + let cursor: unknown = result; + for (const segment of segments.slice(0, -1)) { + cursor = + typeof cursor === "object" && cursor !== null + ? (cursor as Record)[segment] + : undefined; + } + if (typeof cursor !== "object" || cursor === null) continue; + const key = segments[segments.length - 1]!; + const value = op["value"]; + if (Array.isArray(cursor)) { + const index = key === "-" ? cursor.length : Number(key); + if (!Number.isInteger(index) || index < 0 || index > cursor.length) continue; + if (op["op"] === "remove") cursor.splice(index, 1); + else if (op["op"] === "add") cursor.splice(index, 0, value); + else if (op["op"] === "append" && typeof cursor[index] === "string") + cursor[index] = `${cursor[index]}${typeof value === "string" ? value : ""}`; + else cursor[index] = value; + continue; + } + const record = cursor as Record; + if (op["op"] === "remove") delete record[key]; + else if (op["op"] === "append") { + const current = record[key]; + record[key] = + `${typeof current === "string" ? current : ""}${typeof value === "string" ? value : ""}`; + } else record[key] = value; + } + return result; +} + export const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; /** Mistral Vibe's application rate-limit code, outside the JSON-RPC reserved range. */ From 1534bfbf11d79f748d9f6d1bd3ffcda3e9637e78 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 12:39:53 +0100 Subject: [PATCH 11/18] feat(provider-vibe): show tool calls and reasoning in the timeline Vibe reports tool calls as `effect` history entries and thinking as `reasoning` entries; the adapter only projected messages, so threads showed no tool activity. Map effects to command_execution, file_change, file_search, web_search, or dynamic_tool items, and reasoning to reasoning items, with live status from the effect state. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 27 ++++ .../src/server/historyItems.test.ts | 103 ++++++++++++ .../provider-vibe/src/server/historyItems.ts | 153 ++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 packages/provider-vibe/src/server/historyItems.test.ts create mode 100644 packages/provider-vibe/src/server/historyItems.ts diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 303123f3431fb..2387b97d2a98d 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -45,6 +45,7 @@ import { vibeAgentForRuntimeMode, vibeWorktreeInputForCwd, } from "./appServerSupport.ts"; +import { vibeEffectSettled, vibeHistoryTurnItem } from "./historyItems.ts"; const VibeProviderCapabilitiesV2 = { sessions: { @@ -284,6 +285,32 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( threadId: ThreadId, ) { if (!state.thread) return; + if (entry["type"] !== "message") { + const timestamp = yield* DateTime.now; + const turnItem = vibeHistoryTurnItem( + { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: VIBE_PROVIDER, + nativeItemId, + }), + threadId, + runId: state.currentRun?.runId ?? null, + nodeId: state.currentRun?.rootNodeId ?? null, + providerThreadId: state.thread.id, + providerTurnId: state.activeTurn?.providerTurnId ?? null, + nativeItemRef: nativeRef(nativeItemId), + parentItemId: null, + ordinal: 0, + startedAt: null, + completedAt: + entry["type"] === "reasoning" || vibeEffectSettled(entry) ? timestamp : null, + updatedAt: timestamp, + }, + entry, + ); + if (turnItem) yield* emit({ type: "turn_item.updated", driver: VIBE_PROVIDER, turnItem }); + return; + } const role = str(entry["role"]); // User input is echoed back as a history entry and the app already owns it; system // entries are not conversation. Only assistant entries become messages. diff --git a/packages/provider-vibe/src/server/historyItems.test.ts b/packages/provider-vibe/src/server/historyItems.test.ts new file mode 100644 index 0000000000000..253f30ff582cf --- /dev/null +++ b/packages/provider-vibe/src/server/historyItems.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { vibeEffectSettled, vibeHistoryTurnItem } from "./historyItems.ts"; + +const base = { + id: TurnItemId.make("item-1"), + threadId: ThreadId.make("thread-1"), + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), +}; + +const effect = (detail: Record, state: Record) => ({ + type: "effect", + title: "Tool", + detail: { display: { summary: "Summary" }, ...detail }, + state, +}); + +describe("vibeHistoryTurnItem", () => { + it.each([ + [ + "shell", + effect( + { kind: "shell", toolName: "bash", input: { command: "ls -la" } }, + { status: "completed", output: { stdout: "a\nb", stderr: "", exitCode: 0 } }, + ), + { + type: "command_execution", + status: "completed", + input: "ls -la", + output: "a\nb", + exitCode: 0, + }, + ], + [ + "streaming shell", + effect( + { kind: "shell", toolName: "bash", input: { command: "make" } }, + { status: "running", outputText: "building" }, + ), + { type: "command_execution", status: "running", output: "building" }, + ], + [ + "file edit", + effect( + { + kind: "file_edit", + toolName: "edit", + input: { filePath: "/repo/a.ts", oldString: "x", newString: "y" }, + }, + { status: "completed" }, + ), + { type: "file_change", fileName: "/repo/a.ts", oldStr: "x", newStr: "y" }, + ], + [ + "file write", + effect( + { kind: "file_write", toolName: "write", input: { filePath: "/repo/b.ts", content: "z" } }, + { status: "blocked", callbackId: "cb" }, + ), + { type: "file_change", status: "waiting", fileName: "/repo/b.ts", newStr: "z" }, + ], + [ + "failed generic tool", + effect( + { kind: "tool", toolName: "mcp__db__query", input: { sql: "select 1" } }, + { status: "failed", error: { message: "boom" } }, + ), + { + type: "dynamic_tool", + status: "failed", + toolName: "mcp__db__query", + input: { sql: "select 1" }, + output: "boom", + title: "Summary", + }, + ], + ["reasoning", { type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking" }], + ])("projects %s entries", (_name, entry, expected) => { + expect(vibeHistoryTurnItem(base, entry)).toMatchObject(expected); + }); + + it("skips entries the timeline does not show", () => { + expect(vibeHistoryTurnItem(base, { type: "callback", title: "Approve?" })).toBeUndefined(); + expect(vibeHistoryTurnItem(base, { type: "reasoning", text: "" })).toBeUndefined(); + }); + + it("settles only terminal effect states", () => { + expect(vibeEffectSettled({ state: { status: "running" } })).toBe(false); + expect(vibeEffectSettled({ state: { status: "blocked" } })).toBe(false); + expect(vibeEffectSettled({ state: { status: "skipped" } })).toBe(true); + }); +}); diff --git a/packages/provider-vibe/src/server/historyItems.ts b/packages/provider-vibe/src/server/historyItems.ts new file mode 100644 index 0000000000000..f9fe68287e2d9 --- /dev/null +++ b/packages/provider-vibe/src/server/historyItems.ts @@ -0,0 +1,153 @@ +import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; + +type HistoryItemBase = Omit< + Extract, + "type" | "toolName" | "input" | "output" | "status" | "title" +>; + +const asRecord = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const str = (value: unknown): string | undefined => + typeof value === "string" && value.trim().length > 0 ? value : undefined; + +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +// Search output stays on the timeline wire, so keep it a preview. +const SEARCH_PREVIEW_MAX_CHARS = 8_000; + +const EFFECT_STATUS: Record = { + pending: "pending", + running: "running", + blocked: "waiting", + completed: "completed", + skipped: "completed", + failed: "failed", + cancelled: "cancelled", +}; + +/** Whether a Vibe effect state is terminal, so the item gets a `completedAt`. */ +export function vibeEffectSettled(entry: Record): boolean { + const status = str(asRecord(entry["state"])?.["status"]); + return ( + status !== undefined && status !== "pending" && status !== "running" && status !== "blocked" + ); +} + +function effectOutput(state: Record | undefined): string | undefined { + const outputText = text(state?.["outputText"]); + if (outputText) return outputText; + const output = state?.["output"]; + const record = asRecord(output); + const shellOutput = + text(record?.["output"]) || + [text(record?.["stdout"]), text(record?.["stderr"])].filter(Boolean).join("\n"); + if (shellOutput) return shellOutput; + if (output === null || output === undefined) { + return str(asRecord(state?.["error"])?.["message"]) ?? str(state?.["reason"]); + } + return typeof output === "string" ? output : JSON.stringify(output, null, 2); +} + +/** + * Projects a non-message Vibe history entry (`reasoning` or `effect`) to a turn item. + * Returns undefined for entries the timeline does not show (callbacks, notices, checkpoints). + */ +export function vibeHistoryTurnItem( + base: HistoryItemBase, + entry: Record, +): OrchestrationV2TurnItem | undefined { + if (entry["type"] === "reasoning") { + const reasoning = text(entry["text"]); + if (!reasoning) return undefined; + return { + ...base, + status: "completed", + title: null, + type: "reasoning", + text: reasoning, + streaming: false, + }; + } + if (entry["type"] !== "effect") return undefined; + + const detail = asRecord(entry["detail"]); + const state = asRecord(entry["state"]); + const input = asRecord(detail?.["input"]); + const toolName = str(detail?.["toolName"]) ?? null; + const status = EFFECT_STATUS[str(state?.["status"]) ?? ""] ?? "running"; + const title = str(asRecord(detail?.["display"])?.["summary"]) ?? str(entry["title"]) ?? null; + const output = effectOutput(state); + const common = { ...base, status, title }; + + switch (str(detail?.["kind"])) { + case "shell": { + const exitCode = asRecord(state?.["output"])?.["exitCode"]; + return { + ...common, + type: "command_execution", + input: str(input?.["command"]) ?? title ?? toolName ?? "shell", + ...(output === undefined ? {} : { output }), + ...(typeof exitCode === "number" && Number.isInteger(exitCode) ? { exitCode } : {}), + ...(status === "failed" ? { outputIndicatesFailure: true } : {}), + }; + } + case "file_edit": + case "file_write": { + const fileName = str(input?.["filePath"]); + if (fileName === undefined) break; + const changes = Array.isArray(input?.["changes"]) ? input["changes"].map(asRecord) : []; + const oldStr = + text(input?.["oldString"]) ?? + (changes.length > 0 + ? changes.map((c) => text(c?.["oldString"]) ?? "").join("\n") + : undefined); + const newStr = + text(input?.["newString"]) ?? + text(input?.["content"]) ?? + (changes.length > 0 + ? changes.map((c) => text(c?.["newString"]) ?? "").join("\n") + : undefined); + return { + ...common, + type: "file_change", + fileName, + ...(oldStr === undefined ? {} : { oldStr }), + ...(newStr === undefined ? {} : { newStr }), + }; + } + case "file_search": { + const pattern = str(input?.["pattern"]); + const root = str(input?.["path"]) ?? pattern; + return { + ...common, + type: "file_search", + ...(pattern === undefined ? {} : { pattern }), + ...(!output?.trim() || root === undefined + ? {} + : { results: [{ fileName: root, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }] }), + }; + } + case "web_search": { + const query = str(input?.["query"]); + return { + ...common, + type: "web_search", + ...(query === undefined ? {} : { patterns: [query] }), + ...(!output?.trim() + ? {} + : { results: [{ snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }] }), + }; + } + } + return { + ...common, + type: "dynamic_tool", + toolName, + input: detail?.["input"] ?? null, + ...(output === undefined ? {} : { output }), + }; +} From 663b1554eb0b6b49c35a7965423dec44d592f592 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 12:48:54 +0100 Subject: [PATCH 12/18] feat(provider-vibe): give Vibe agents T3's MCP tools and instructions Vibe sessions never received T3's MCP server, so Vibe agents could not delegate, read threads, or use the browser and device tools. Pass the thread's T3 MCP endpoint as a session MCP server on start and resume, deliver T3's orchestration instructions on the prompt (Vibe has no appendable system field), set T3CODE_THREAD_ID and the device-tool environment on the app-server, and strip the instruction envelope from replayed user messages. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/server/adapter.ts | 44 +++++++++++++++++-- .../src/server/appServerSupport.test.ts | 33 ++++++++++++++ .../src/server/appServerSupport.ts | 25 +++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 2387b97d2a98d..67b9d3736ff6a 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -35,10 +35,15 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; +import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { MISTRAL_VIBE_RATE_LIMITED, applyVibeHistoryPatch, + stripT3PromptInstructions, + vibeMcpServers, VIBE_PROVIDER, buildVibeSpawnInput, resolveVibeModelId, @@ -217,6 +222,8 @@ interface AdapterState { | undefined; /** History entries by native id, so `history/entryUpdated` patches can be applied. */ entries: Map>; + /** T3 instructions ride on the prompt, so send each block once per app-server session. */ + instructionsDelivered: { orchestration: boolean; device: boolean }; /** Maps a T3 runtime-request id to the native callback id answered via `callback/result`. */ callbackByRequest: Map< RuntimeRequestId, @@ -242,12 +249,21 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const openSessionInner = Effect.fn("VibeAdapterV2.openSession")(function* ( input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, ) { const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; - const spawn = buildVibeSpawnInput(options.settings, cwd, options.environment); + const spawn = buildVibeSpawnInput( + options.settings, + cwd, + withAgentDeviceEnvironment( + // Lets tools run from the agent's shell find their T3 thread, as Claude and Codex do. + { ...(options.environment ?? process.env), T3CODE_THREAD_ID: input.threadId }, + yield* mcpSessions.read(input.threadId), + ), + ); const child = yield* spawner .spawn( ChildProcess.make(spawn.command, [...spawn.args], { @@ -393,6 +409,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( currentRun: undefined, entries: new Map(), callbackByRequest: new Map(), + instructionsDelivered: { orchestration: false, device: false }, }; const sessionIdOf = () => state.thread?.nativeThreadRef?.nativeId ?? undefined; @@ -708,7 +725,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( ); const threadCwd = ensureInput.runtimePolicy.cwd ?? cwd; const nativeId = ensureInput.existingProviderThread?.nativeThreadRef?.nativeId ?? undefined; + const mcpSession = yield* mcpSessions.read(ensureInput.threadId); const agentConfig = { + mcpServers: vibeMcpServers(mcpSession), cwd: threadCwd, headless: true, trustWorkspace: options.settings.trustWorkspace, @@ -810,13 +829,28 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( ), ), ); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const hasDeviceTools = mcpSession?.capabilities?.has("device") === true; + const promptText = t3OrchestrationPrompt({ + prompt: turnInput.message.text, + hasT3Mcp: mcpSession !== undefined, + hasDeviceTools, + orchestrationDelivered: state.instructionsDelivered.orchestration, + deviceInstructionsDelivered: state.instructionsDelivered.device, + }); yield* Effect.mapError( client.sessionTurnEnqueue({ sessionId: nativeSessionId, - entries: [{ role: "user", content: [{ type: "text", text: turnInput.message.text }] }], + entries: [{ role: "user", content: [{ type: "text", text: promptText }] }], }), protocolError, ); + if (mcpSession !== undefined) { + state.instructionsDelivered = { + orchestration: true, + device: state.instructionsDelivered.device || hasDeviceTools, + }; + } yield* updateThread({ status: "active" }); }); @@ -971,9 +1005,10 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const now = yield* DateTime.now; const messages: OrchestrationV2ConversationMessage[] = []; for (const entry of historyEntries) { - const text = entryText(entry); - if (!text) continue; const role = str(entry["role"]) === "user" ? "user" : "assistant"; + const text = + role === "user" ? stripT3PromptInstructions(entryText(entry)) : entryText(entry); + if (!text) continue; const nativeItemId = str(entry["id"]) ?? `entry-${messages.length}`; messages.push({ createdBy: "agent", @@ -1181,4 +1216,5 @@ export type VibeAdapterV2Env = | Path.Path | SecureRandom.SecureRandom | IdAllocator.IdAllocatorV2 + | McpProviderSessions.McpProviderSessions | ProviderHost.ProviderHost; diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts index 4c61470e3bb58..e66098cf429e3 100644 --- a/packages/provider-vibe/src/server/appServerSupport.test.ts +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; +import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { VibeSettings } from "../settings.ts"; import { DEFAULT_VIBE_MODEL, applyVibeHistoryPatch, + stripT3PromptInstructions, + vibeMcpServers, VIBE_DEFAULT_BINARY, buildVibeEnvironment, buildVibeSpawnInput, @@ -126,3 +129,33 @@ describe("applyVibeHistoryPatch", () => { ]); }); }); + +describe("vibeMcpServers", () => { + it("passes the thread's T3 MCP endpoint as an authorized streamable-http server", () => { + expect(vibeMcpServers(undefined)).toEqual([]); + expect( + vibeMcpServers({ endpoint: "http://127.0.0.1:1/mcp", authorizationHeader: "Bearer t" }), + ).toEqual([ + { + transport: "streamable-http", + name: "t3-code", + url: "http://127.0.0.1:1/mcp", + headers: { Authorization: "Bearer t" }, + }, + ]); + }); +}); + +describe("stripT3PromptInstructions", () => { + it("recovers the user's text from T3's prompt envelope and leaves plain text alone", () => { + const prompt = t3OrchestrationPrompt({ + prompt: "fix the bug\n\nplease", + hasT3Mcp: true, + hasDeviceTools: true, + orchestrationDelivered: false, + deviceInstructionsDelivered: false, + }); + expect(stripT3PromptInstructions(prompt)).toBe("fix the bug\n\nplease"); + expect(stripT3PromptInstructions("plain")).toBe("plain"); + }); +}); diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index a50a4f6ff9f78..54b06f45b71e5 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -1,3 +1,4 @@ +import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; import { type ProviderInteractionMode, ProviderDriverKind, @@ -71,6 +72,30 @@ export function applyVibeHistoryPatch( return result; } +/** T3's per-thread MCP endpoint as a Vibe session MCP server, so Vibe agents get T3's tools. */ +export function vibeMcpServers( + session: Pick | undefined, +) { + return session === undefined + ? [] + : [ + { + transport: "streamable-http" as const, + name: "t3-code", + url: session.endpoint, + headers: { Authorization: session.authorizationHeader }, + }, + ]; +} + +const T3_PROMPT_ENVELOPE = + /^[\s\S]*?<\/t3_code_orchestration_instructions>\n\n\n([\s\S]*)\n<\/user_request>$/; + +/** Vibe stores the prompt as sent; replayed user messages drop T3's instruction envelope. */ +export function stripT3PromptInstructions(text: string): string { + return T3_PROMPT_ENVELOPE.exec(text)?.[1] ?? text; +} + export const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; /** Mistral Vibe's application rate-limit code, outside the JSON-RPC reserved range. */ From 625bca72f8bf643492e7767004f00ab9ee08fdc7 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 12:59:13 +0100 Subject: [PATCH 13/18] fix(provider-vibe): label the provider Vibe and polish tool names and limits Shorten the provider label from "Mistral Vibe" to "Vibe" and the usage window from "Monthly allowance" to "Monthly", matching the plain window names other providers use. Vibe names MCP tools `mcp_.`, so T3's own tools showed raw names; normalize them to `mcp____` so they get T3's tool labels. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/contracts/src/model.ts | 2 +- packages/provider-vibe/src/client.ts | 2 +- packages/provider-vibe/src/server/adapter.ts | 13 +++++-------- packages/provider-vibe/src/server/driver.ts | 2 +- .../src/server/historyItems.test.ts | 8 ++++++++ .../provider-vibe/src/server/historyItems.ts | 3 ++- packages/provider-vibe/src/server/status.ts | 14 +++++++------- .../provider-vibe/src/server/textGeneration.ts | 16 +++++++--------- packages/provider-vibe/src/server/usageLimits.ts | 2 +- 9 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index c8b4f05abf53f..d16ec83ef41b5 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -209,5 +209,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", - [VIBE_DRIVER_KIND]: "Mistral Vibe", + [VIBE_DRIVER_KIND]: "Vibe", }; diff --git a/packages/provider-vibe/src/client.ts b/packages/provider-vibe/src/client.ts index ab3cec108cb28..eb47a2ff0a6da 100644 --- a/packages/provider-vibe/src/client.ts +++ b/packages/provider-vibe/src/client.ts @@ -5,7 +5,7 @@ import { VibeSettings } from "./settings.ts"; export const vibeClient = defineProviderClient({ driverKind: ProviderDriverKind.make("vibe"), - label: "Mistral Vibe", + label: "Vibe", modelFamilies: ["Mistral ", "Devstral"], acpRegistryAgentIds: ["mistral-vibe"], settingsSchema: VibeSettings, diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 67b9d3736ff6a..78da1c4c0e666 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -189,7 +189,7 @@ export function vibeTurnFailure(turn: { }): ReturnType | undefined { const error = asRecord(turn.error); const code = error?.["code"]; - const message = str(error?.["message"]) ?? str(turn.stopReason) ?? "Mistral Vibe turn failed."; + const message = str(error?.["message"]) ?? str(turn.stopReason) ?? "Vibe turn failed."; return makeProviderFailure({ message, ...(typeof code === "number" || typeof code === "string" @@ -242,7 +242,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const protocolError = (cause: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ driver: VIBE_PROVIDER, - detail: "Mistral Vibe app-server request failed.", + detail: "Vibe app-server request failed.", cause, }); @@ -821,12 +821,9 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( protocolError, ).pipe( Effect.catchCause((cause) => - Effect.logWarning( - "Mistral Vibe rejected the model write; the turn keeps the session model.", - { - cause, - }, - ), + Effect.logWarning("Vibe rejected the model write; the turn keeps the session model.", { + cause, + }), ), ); const mcpSession = yield* mcpSessions.read(turnInput.threadId); diff --git a/packages/provider-vibe/src/server/driver.ts b/packages/provider-vibe/src/server/driver.ts index d91ea8b17acd7..67323da2e691e 100644 --- a/packages/provider-vibe/src/server/driver.ts +++ b/packages/provider-vibe/src/server/driver.ts @@ -59,7 +59,7 @@ export type VibeDriverEnv = export const VibeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { - displayName: "Mistral Vibe", + displayName: "Vibe", supportsMultipleInstances: true, }, configSchema: VibeSettings, diff --git a/packages/provider-vibe/src/server/historyItems.test.ts b/packages/provider-vibe/src/server/historyItems.test.ts index 253f30ff582cf..20cd8cef2979e 100644 --- a/packages/provider-vibe/src/server/historyItems.test.ts +++ b/packages/provider-vibe/src/server/historyItems.test.ts @@ -85,6 +85,14 @@ describe("vibeHistoryTurnItem", () => { title: "Summary", }, ], + [ + "Vibe-named MCP tool", + effect( + { kind: "tool", toolName: "mcp_t3_code.t3_thread_read", input: { threadId: "t" } }, + { status: "completed", output: "{}" }, + ), + { type: "dynamic_tool", toolName: "mcp__t3_code__t3_thread_read" }, + ], ["reasoning", { type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking" }], ])("projects %s entries", (_name, entry, expected) => { expect(vibeHistoryTurnItem(base, entry)).toMatchObject(expected); diff --git a/packages/provider-vibe/src/server/historyItems.ts b/packages/provider-vibe/src/server/historyItems.ts index f9fe68287e2d9..8edfd65895c6a 100644 --- a/packages/provider-vibe/src/server/historyItems.ts +++ b/packages/provider-vibe/src/server/historyItems.ts @@ -77,7 +77,8 @@ export function vibeHistoryTurnItem( const detail = asRecord(entry["detail"]); const state = asRecord(entry["state"]); const input = asRecord(detail?.["input"]); - const toolName = str(detail?.["toolName"]) ?? null; + // Vibe names MCP tools `mcp_.`; T3's tool labels expect `mcp____`. + const toolName = str(detail?.["toolName"])?.replace(/^mcp_([^.]+)\.(.+)$/, "mcp__$1__$2") ?? null; const status = EFFECT_STATUS[str(state?.["status"]) ?? ""] ?? "running"; const title = str(asRecord(detail?.["display"])?.["summary"]) ?? str(entry["title"]) ?? null; const output = effectOutput(state); diff --git a/packages/provider-vibe/src/server/status.ts b/packages/provider-vibe/src/server/status.ts index 6200b179ff47e..a72a8e0309c6f 100644 --- a/packages/provider-vibe/src/server/status.ts +++ b/packages/provider-vibe/src/server/status.ts @@ -21,7 +21,7 @@ import { DEFAULT_VIBE_MODEL, withVibeAppServerClient } from "./appServerSupport. import { accountViewToUsageLimits } from "./usageLimits.ts"; const VIBE_PRESENTATION = { - displayName: "Mistral Vibe", + displayName: "Vibe", badgeLabel: "Early Access", showInteractionModeToggle: true, supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"] as const, @@ -49,11 +49,11 @@ function authFromAccount(account: AccountView): ServerProviderAuth { return { status: "authenticated", type: "vibe", - label: account.plan?.title ?? account.plan?.name ?? "Mistral Vibe", + label: account.plan?.title ?? account.plan?.name ?? "Vibe", }; case "missing_key": case "unauthorized": - return { status: "unauthenticated", type: "vibe", label: "Mistral Vibe" }; + return { status: "unauthenticated", type: "vibe", label: "Vibe" }; default: return { status: "unknown" }; } @@ -133,7 +133,7 @@ export const buildInitialVibeProviderSnapshot = Effect.fn( version: null, status: "warning", auth: { status: "unknown" }, - message: "Mistral Vibe is disabled in T3 Code settings.", + message: "Vibe is disabled in T3 Code settings.", }, }); }); @@ -207,19 +207,19 @@ const probeVibeAppServer = Effect.fn("VibeStatus.probeVibeAppServer")(function* models: [...FALLBACK_MODELS], defaultAgent: undefined, installed: true, - message: "Mistral Vibe app server timed out while probing.", + message: "Vibe app server timed out while probing.", } satisfies VibeProbeResult) : option.value, ), Effect.catchCause((cause) => - Effect.as(Effect.logWarning("Mistral Vibe app server probe failed", { cause }), { + Effect.as(Effect.logWarning("Vibe app server probe failed", { cause }), { version: null, auth: { status: "unknown" }, usageLimits: undefined, models: [...FALLBACK_MODELS], defaultAgent: undefined, installed: false, - message: "Mistral Vibe app server probe failed.", + message: "Vibe app server probe failed.", } satisfies VibeProbeResult), ), ); diff --git a/packages/provider-vibe/src/server/textGeneration.ts b/packages/provider-vibe/src/server/textGeneration.ts index 3e5782d2b2abc..793f05f46c477 100644 --- a/packages/provider-vibe/src/server/textGeneration.ts +++ b/packages/provider-vibe/src/server/textGeneration.ts @@ -89,7 +89,7 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi const error = asRecord(terminalTurn?.error); return yield* new TextGenerationError({ operation, - detail: `Mistral Vibe turn ended as ${terminalTurn?.status ?? "unknown"}: ${str(error?.["message"]) ?? "no assistant reply"}`, + detail: `Vibe turn ended as ${terminalTurn?.status ?? "unknown"}: ${str(error?.["message"]) ?? "no assistant reply"}`, }); } const history = yield* client.sessionHistoryList({ sessionId, limit: 100 }); @@ -109,9 +109,7 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi Effect.timeoutOption(VIBE_TEXT_GEN_TIMEOUT_MS), Effect.flatMap((option) => option._tag === "None" - ? Effect.fail( - new TextGenerationError({ operation, detail: "Mistral Vibe request timed out." }), - ) + ? Effect.fail(new TextGenerationError({ operation, detail: "Vibe request timed out." })) : Effect.succeed(option.value), ), Effect.mapError((cause: VibeError.VibeAppServerError | TextGenerationError) => @@ -119,7 +117,7 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi ? cause : new TextGenerationError({ operation, - detail: "Mistral Vibe app server request failed.", + detail: "Vibe app server request failed.", cause, }), ), @@ -128,10 +126,10 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi if (!output) { return yield* new TextGenerationError({ operation, - detail: "Mistral Vibe returned empty output.", + detail: "Vibe returned empty output.", }); } - return yield* TextGenerationOperations.decodeJsonReply(request, "Mistral Vibe", output); + return yield* TextGenerationOperations.decodeJsonReply(request, "Vibe", output); }, (effect, request) => { const { operation } = request; @@ -141,7 +139,7 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi ? cause : new TextGenerationError({ operation, - detail: "Mistral Vibe text generation failed.", + detail: "Vibe text generation failed.", cause, }), ), @@ -151,6 +149,6 @@ export const makeVibeTextGeneration = Effect.fn("makeVibeTextGeneration")(functi return TextGenerationOperations.fromRunner( "VibeTextGeneration", - TextGenerationOperations.withoutInvestigation("Mistral Vibe", runVibeJson), + TextGenerationOperations.withoutInvestigation("Vibe", runVibeJson), ); }); diff --git a/packages/provider-vibe/src/server/usageLimits.ts b/packages/provider-vibe/src/server/usageLimits.ts index a25f3bdd45cfd..7d01fab49501c 100644 --- a/packages/provider-vibe/src/server/usageLimits.ts +++ b/packages/provider-vibe/src/server/usageLimits.ts @@ -32,7 +32,7 @@ export function accountViewToUsageLimits( const window: ServerProviderUsageWindow = { id: "vibe-code-monthly", kind: "monthly", - label: budget.paygEnabled === true ? "Monthly allowance (pay-as-you-go)" : "Monthly allowance", + label: budget.paygEnabled === true ? "Monthly (pay-as-you-go)" : "Monthly", usedPercent: clampPercent(budget.usagePercentage), ...(budget.resetAt ? { resetsAt: budget.resetAt } : {}), }; From 1cb46b66cf54336c3b85d0ad5c5dd4ded3f42217 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 13:01:48 +0100 Subject: [PATCH 14/18] fix(provider-vibe): list Vibe under "Show more" in the add-provider dialog Vibe is early access like Kiro, so it belongs with the non-featured providers rather than the main grid. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-vibe/src/client.ts b/packages/provider-vibe/src/client.ts index eb47a2ff0a6da..c3ecc72eb9dfd 100644 --- a/packages/provider-vibe/src/client.ts +++ b/packages/provider-vibe/src/client.ts @@ -6,6 +6,7 @@ import { VibeSettings } from "./settings.ts"; export const vibeClient = defineProviderClient({ driverKind: ProviderDriverKind.make("vibe"), label: "Vibe", + featured: false, modelFamilies: ["Mistral ", "Devstral"], acpRegistryAgentIds: ["mistral-vibe"], settingsSchema: VibeSettings, From 8a01686bdd7ee16b75073b8c2ba740b809547999 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 13:08:03 +0100 Subject: [PATCH 15/18] fix(provider-vibe): show pace on the monthly usage limit The pace indicator needs the window length, which the Vibe window never set. Derive it from the reset date: the allowance renews monthly, so the window spans the calendar month before the reset. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../provider-vibe/src/server/usageLimits.test.ts | 3 +++ packages/provider-vibe/src/server/usageLimits.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/provider-vibe/src/server/usageLimits.test.ts b/packages/provider-vibe/src/server/usageLimits.test.ts index 6a8019aaa37e9..67d7942919f36 100644 --- a/packages/provider-vibe/src/server/usageLimits.test.ts +++ b/packages/provider-vibe/src/server/usageLimits.test.ts @@ -24,6 +24,8 @@ describe("accountViewToUsageLimits", () => { kind: "monthly", usedPercent: 42.5, resetsAt: "2026-12-01T00:00:00Z", + // November has 30 days; the length lets the UI show pace. + windowDurationMins: 30 * 24 * 60, }); }); @@ -33,6 +35,7 @@ describe("accountViewToUsageLimits", () => { "2026-11-15T00:00:00.000Z", ); expect(limits.windows[0]?.usedPercent).toBe(100); + expect(limits.windows[0]?.windowDurationMins).toBeUndefined(); expect(limits.windows[0]?.label).toContain("pay-as-you-go"); }); diff --git a/packages/provider-vibe/src/server/usageLimits.ts b/packages/provider-vibe/src/server/usageLimits.ts index 7d01fab49501c..1b5018e711072 100644 --- a/packages/provider-vibe/src/server/usageLimits.ts +++ b/packages/provider-vibe/src/server/usageLimits.ts @@ -1,4 +1,6 @@ import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import type { AccountView } from "@t3tools/vibe-app-server/client"; import { clampPercent, @@ -14,6 +16,18 @@ export const MISTRAL_USAGE_URL = "https://console.mistral.ai/usage"; * reset timestamp. Accounts without a budget (API keys, self-hosted) report * unsupported plus a console link, like Codex's managed account. */ +/** The allowance renews monthly, so the window spans the calendar month before its reset. */ +function monthlyWindowDuration(resetAt: string | null | undefined) { + const end = resetAt ? DateTime.make(resetAt) : Option.none(); + if (Option.isNone(end)) return {}; + const start = DateTime.subtract(end.value, { months: 1 }); + return { + windowDurationMins: Math.round( + (DateTime.toEpochMillis(end.value) - DateTime.toEpochMillis(start)) / 60_000, + ), + }; +} + export function accountViewToUsageLimits( account: AccountView, checkedAt: string, @@ -35,6 +49,7 @@ export function accountViewToUsageLimits( label: budget.paygEnabled === true ? "Monthly (pay-as-you-go)" : "Monthly", usedPercent: clampPercent(budget.usagePercentage), ...(budget.resetAt ? { resetsAt: budget.resetAt } : {}), + ...monthlyWindowDuration(budget.resetAt), }; return makeUsageLimits({ checkedAt, windows: [window] }); } From bcec70c775425ace1b4e413f5e0a717ed59ae877 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Fri, 6 Nov 2026 23:42:51 +0100 Subject: [PATCH 16/18] fix(provider-vibe): run No-project sessions in place instead of failing setup Vibe's `existing` worktree request requires the session cwd to be a linked git worktree; for a No-project scratch directory it raised "Path is not inside a git repository", which abandoned the deferred session setup and made every later request fail with the conflict "The session's workspace could not be prepared". Only send the existing-worktree input when the cwd carries a `.git` file; otherwise send `worktree: null` so the session runs in place. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- packages/provider-vibe/package.json | 1 + packages/provider-vibe/src/server/adapter.ts | 6 ++- .../src/server/appServerSupport.test.ts | 37 ++++++++++++++++--- .../src/server/appServerSupport.ts | 24 +++++++++--- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/provider-vibe/package.json b/packages/provider-vibe/package.json index d2198eb17c1d4..034a14fd16538 100644 --- a/packages/provider-vibe/package.json +++ b/packages/provider-vibe/package.json @@ -28,6 +28,7 @@ "effect": "catalog:" }, "devDependencies": { + "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index 78da1c4c0e666..eb6a407bbf839 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -726,12 +726,16 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const threadCwd = ensureInput.runtimePolicy.cwd ?? cwd; const nativeId = ensureInput.existingProviderThread?.nativeThreadRef?.nativeId ?? undefined; const mcpSession = yield* mcpSessions.read(ensureInput.threadId); + const worktree = yield* vibeWorktreeInputForCwd(threadCwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const agentConfig = { mcpServers: vibeMcpServers(mcpSession), cwd: threadCwd, headless: true, trustWorkspace: options.settings.trustWorkspace, - worktree: vibeWorktreeInputForCwd(threadCwd), + worktree, agent: agent.agent, autoApprove: agent.autoApprove, }; diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts index e66098cf429e3..f7b095ea21f66 100644 --- a/packages/provider-vibe/src/server/appServerSupport.test.ts +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; @@ -94,12 +98,33 @@ describe("resolveVibeModelId", () => { }); describe("vibeWorktreeInputForCwd", () => { - it("adopts the thread's existing checkout as the session worktree", () => { - expect(vibeWorktreeInputForCwd("/repo/.t3/worktrees/thread-1")).toEqual({ - kind: "existing", - cwd: "/repo/.t3/worktrees/thread-1", - }); - }); + it.effect("adopts a linked worktree checkout (`.git` file) as the session worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-worktree-" }); + yield* fs.writeFileString(path.join(cwd, ".git"), "gitdir: /repo/.git/worktrees/thread-1\n"); + expect(yield* vibeWorktreeInputForCwd(cwd)).toEqual({ kind: "existing", cwd }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("runs in place in the main checkout (`.git` directory)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-main-checkout-" }); + yield* fs.makeDirectory(path.join(cwd, ".git")); + expect(yield* vibeWorktreeInputForCwd(cwd)).toBeNull(); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("runs in place outside any git repository (No-project scratch dirs)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-no-project-" }); + expect(yield* vibeWorktreeInputForCwd(cwd)).toBeNull(); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); describe("applyVibeHistoryPatch", () => { diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index 54b06f45b71e5..d10e838ee2726 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -7,7 +7,9 @@ import { import type { VibeSettings } from "../settings.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as Scope from "effect/Scope"; @@ -182,13 +184,23 @@ export function vibeAgentForRuntimeMode( } } -/** Native worktree input: adopt the checkout the thread already owns. */ -export function vibeWorktreeInputForCwd(cwd: string): { - readonly kind: "existing"; - readonly cwd: string; -} { +export type VibeWorktreeInput = { readonly kind: "existing"; readonly cwd: string } | null; + +/** + * Native worktree input: adopt the checkout the thread already owns. Only a linked + * worktree carries a `.git` file; Vibe's `existing` request rejects anything else + * (a main checkout, or a directory outside every repo like a No-project scratch + * dir), and that rejection abandons the whole session setup. Those run in place. + */ +export const vibeWorktreeInputForCwd = Effect.fn("vibeWorktreeInputForCwd")(function* ( + cwd: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gitEntry = yield* fs.stat(path.join(cwd, ".git")).pipe(Effect.orElseSucceed(() => null)); + if (gitEntry === null || gitEntry.type !== "File") return null; return { kind: "existing", cwd }; -} +}); export interface VibeAppServerHandle { readonly client: VibeAppServer.VibeAppServerClient["Service"]; From f9756fb381c765723521379d74e500b9b1267d5c Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Sat, 7 Nov 2026 00:00:39 +0100 Subject: [PATCH 17/18] fix(provider-vibe): Vibe threads no longer hang, and No-project chats start One vibe-app-server connection streams events for only the session it last attached, but the adapter declared multi-thread sessions, so every Vibe thread shared one process and one adapter state. Starting a second Vibe thread silently detached the first: its turn kept running in Vibe while T3 never saw another event or the completion, and steering failed because the recorded provider thread had been overwritten. Vibe also rejects `worktree` on session/resume, so every resume after a restart failed and fell back to a fresh session, and `worktree: existing` fails Vibe's deferred workspace setup outside a linked worktree, which is what broke No-project chats ("The session's workspace could not be prepared"). - Declare one provider thread per session and use the session id the orchestrator allocates, so each Vibe thread gets its own app-server. - Stop sending `worktree`; Vibe runs in place in the thread's checkout, which is what `existing` resolved to anyway. This drops the `.git` probe and its dev dependency, which the lockfile never picked up. - Route forks through T3's context handoff. The native fork reused the source thread's session and passed a T3 turn id as Vibe's `afterTurnId`. --- packages/provider-vibe/package.json | 1 - packages/provider-vibe/src/server/adapter.ts | 68 +++---------------- .../src/server/appServerSupport.test.ts | 35 ---------- .../src/server/appServerSupport.ts | 20 ------ 4 files changed, 10 insertions(+), 114 deletions(-) diff --git a/packages/provider-vibe/package.json b/packages/provider-vibe/package.json index 034a14fd16538..d2198eb17c1d4 100644 --- a/packages/provider-vibe/package.json +++ b/packages/provider-vibe/package.json @@ -28,7 +28,6 @@ "effect": "catalog:" }, "devDependencies": { - "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index eb6a407bbf839..d02c95112e6ca 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -48,13 +48,13 @@ import { buildVibeSpawnInput, resolveVibeModelId, vibeAgentForRuntimeMode, - vibeWorktreeInputForCwd, } from "./appServerSupport.ts"; import { vibeEffectSettled, vibeHistoryTurnItem } from "./historyItems.ts"; const VibeProviderCapabilitiesV2 = { sessions: { - supportsMultipleProviderThreadsPerSession: true, + // A vibe-app-server connection streams events for only its last attached session. + supportsMultipleProviderThreadsPerSession: false, supportsModelSwitchInSession: true, supportsProviderSwitchingViaHandoff: false, supportsRuntimeModeSwitchInSession: true, @@ -64,7 +64,7 @@ const VibeProviderCapabilitiesV2 = { canCreateEmptyThread: true, canReadThreadSnapshot: true, canRollbackThread: true, - canForkThread: true, + canForkThread: false, canForkFromTurn: false, canForkFromSubagentThread: false, exposesNativeThreadId: true, @@ -386,9 +386,7 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( }); const now = yield* DateTime.now; - const providerSessionId = idAllocator.derive.providerSession({ - providerInstanceId: options.instanceId, - }); + const providerSessionId = input.providerSessionId; const state: AdapterState = { session: { id: providerSessionId, @@ -726,16 +724,13 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( const threadCwd = ensureInput.runtimePolicy.cwd ?? cwd; const nativeId = ensureInput.existingProviderThread?.nativeThreadRef?.nativeId ?? undefined; const mcpSession = yield* mcpSessions.read(ensureInput.threadId); - const worktree = yield* vibeWorktreeInputForCwd(threadCwd).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + // No `worktree`: T3 already owns the checkout, so Vibe runs in place at `cwd`. Vibe + // rejects `worktree` on resume, and `existing` fails outside linked worktrees. const agentConfig = { mcpServers: vibeMcpServers(mcpSession), cwd: threadCwd, headless: true, trustWorkspace: options.settings.trustWorkspace, - worktree, agent: agent.agent, autoApprove: agent.autoApprove, }; @@ -1103,57 +1098,14 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( return yield* readThreadSnapshot({ providerThread: rollbackInput.providerThread }); }); - const forkThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["forkThread"] = Effect.fn( - "VibeAdapterV2.forkThread", - )(function* (forkInput) { - const sourceId = forkInput.sourceProviderThread.nativeThreadRef?.nativeId; - if (!sourceId) { - return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ + const forkThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["forkThread"] = (forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterForkThreadError({ driver: VIBE_PROVIDER, providerThreadId: forkInput.sourceProviderThread.id, - }); - } - const forked = yield* Effect.mapError( - client.request("session/fork", { - sourceSessionId: sourceId, - ...(forkInput.providerTurnId !== undefined - ? { afterTurnId: forkInput.providerTurnId } - : {}), + cause: "Vibe forks go through T3's context handoff.", }), - protocolError, - ); - const nativeThreadId = str( - asRecord(asRecord(asRecord(forked)?.["state"])?.["session"])?.["id"], ); - if (!nativeThreadId) { - return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: VIBE_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - }); - } - const createdAt = yield* DateTime.now; - return { - id: idAllocator.derive.providerThread({ - driver: VIBE_PROVIDER, - providerInstanceId: options.instanceId, - nativeThreadId, - }), - driver: VIBE_PROVIDER, - providerInstanceId: options.instanceId, - providerSessionId, - appThreadId: forkInput.targetThreadId, - ownerNodeId: forkInput.ownerNodeId ?? null, - nativeThreadRef: nativeRef(nativeThreadId), - nativeConversationHeadRef: null, - status: "idle" as const, - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: { providerThreadId: forkInput.sourceProviderThread.id }, - createdAt, - updatedAt: createdAt, - } satisfies OrchestrationV2ProviderThread; - }); return { instanceId: options.instanceId, diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts index f7b095ea21f66..535671125d8f9 100644 --- a/packages/provider-vibe/src/server/appServerSupport.test.ts +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -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 { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; @@ -18,7 +14,6 @@ import { buildVibeSpawnInput, resolveVibeModelId, vibeAgentForRuntimeMode, - vibeWorktreeInputForCwd, } from "./appServerSupport.ts"; const decodeSettings = Schema.decodeSync(VibeSettings); @@ -97,36 +92,6 @@ describe("resolveVibeModelId", () => { }); }); -describe("vibeWorktreeInputForCwd", () => { - it.effect("adopts a linked worktree checkout (`.git` file) as the session worktree", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-worktree-" }); - yield* fs.writeFileString(path.join(cwd, ".git"), "gitdir: /repo/.git/worktrees/thread-1\n"); - expect(yield* vibeWorktreeInputForCwd(cwd)).toEqual({ kind: "existing", cwd }); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("runs in place in the main checkout (`.git` directory)", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-main-checkout-" }); - yield* fs.makeDirectory(path.join(cwd, ".git")); - expect(yield* vibeWorktreeInputForCwd(cwd)).toBeNull(); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("runs in place outside any git repository (No-project scratch dirs)", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "vibe-no-project-" }); - expect(yield* vibeWorktreeInputForCwd(cwd)).toBeNull(); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); - describe("applyVibeHistoryPatch", () => { it("concatenates streamed `append` chunks instead of replacing the text", () => { let entry: Record = { content: [{ type: "text", text: "" }] }; diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index d10e838ee2726..3171a41e3e7c7 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -7,9 +7,7 @@ import { import type { VibeSettings } from "../settings.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as Scope from "effect/Scope"; @@ -184,24 +182,6 @@ export function vibeAgentForRuntimeMode( } } -export type VibeWorktreeInput = { readonly kind: "existing"; readonly cwd: string } | null; - -/** - * Native worktree input: adopt the checkout the thread already owns. Only a linked - * worktree carries a `.git` file; Vibe's `existing` request rejects anything else - * (a main checkout, or a directory outside every repo like a No-project scratch - * dir), and that rejection abandons the whole session setup. Those run in place. - */ -export const vibeWorktreeInputForCwd = Effect.fn("vibeWorktreeInputForCwd")(function* ( - cwd: string, -): Effect.fn.Return { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const gitEntry = yield* fs.stat(path.join(cwd, ".git")).pipe(Effect.orElseSucceed(() => null)); - if (gitEntry === null || gitEntry.type !== "File") return null; - return { kind: "existing", cwd }; -}); - export interface VibeAppServerHandle { readonly client: VibeAppServer.VibeAppServerClient["Service"]; readonly child: ChildProcessSpawner.ChildProcessHandle; From 0afcf2acd950588d8569f5da5fc736dfaf787648 Mon Sep 17 00:00:00 2001 From: Gijs Rommers Date: Sat, 7 Nov 2026 00:11:50 +0100 Subject: [PATCH 18/18] fix(provider-vibe): show when Vibe rejects the selected model When Vibe's config/model/write rejected the thread's model (for example a model the signed-in Mistral account isn't offered), the adapter logged a warning and ran the turn on Vibe's fallback model anyway, so the thread claimed one model while another answered. The turn now stops before anything is sent, and the thread names the model Vibe can't run and asks for another. Other model-write failures stop the turn with the generic start-failure guidance instead of being ignored. --- packages/provider-core/src/server/failure.ts | 1 + packages/provider-vibe/src/server/adapter.ts | 30 ++++++++------ .../src/server/appServerSupport.test.ts | 39 +++++++++++++++++++ .../src/server/appServerSupport.ts | 23 +++++++++++ 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index edaf0aa97a743..4e2db25a0cfd1 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -113,6 +113,7 @@ function curatedCauseMessages(cause: unknown): { case "ClaudeMcpRegistrationTimeoutError": case "ClaudeCodeRootRefusalError": case "CursorModelUnavailableError": + case "VibeModelUnavailableError": case "CodexGoalObjectiveTooLongError": case "ProviderSetupError": case "ProviderAdapterThreadInUseError": diff --git a/packages/provider-vibe/src/server/adapter.ts b/packages/provider-vibe/src/server/adapter.ts index d02c95112e6ca..d9377faa18cf3 100644 --- a/packages/provider-vibe/src/server/adapter.ts +++ b/packages/provider-vibe/src/server/adapter.ts @@ -48,6 +48,7 @@ import { buildVibeSpawnInput, resolveVibeModelId, vibeAgentForRuntimeMode, + vibeModelWriteError, } from "./appServerSupport.ts"; import { vibeEffectSettled, vibeHistoryTurnItem } from "./historyItems.ts"; @@ -810,21 +811,26 @@ export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( rootNodeId: turnInput.rootNodeId, }; // A rejected model write must not silently run the turn with the previous model. - yield* Effect.mapError( - client.configModelWrite({ + const modelAlias = resolveVibeModelId(turnInput.modelSelection.model); + yield* client + .configModelWrite({ sessionId: nativeSessionId, - modelAlias: resolveVibeModelId(turnInput.modelSelection.model), + modelAlias, reasoningEffort: getModelSelectionStringOptionValue(turnInput.modelSelection, "thinking"), sessionOnly: true, - }), - protocolError, - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Vibe rejected the model write; the turn keeps the session model.", { - cause, - }), - ), - ); + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: VIBE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause: vibeModelWriteError(modelAlias, cause), + }), + ), + ); const mcpSession = yield* mcpSessions.read(turnInput.threadId); const hasDeviceTools = mcpSession?.capabilities?.has("device") === true; const promptText = t3OrchestrationPrompt({ diff --git a/packages/provider-vibe/src/server/appServerSupport.test.ts b/packages/provider-vibe/src/server/appServerSupport.test.ts index 535671125d8f9..e7616fdf416e6 100644 --- a/packages/provider-vibe/src/server/appServerSupport.test.ts +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; +import { ProviderThreadId, RunId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { VibeAppServerRequestError } from "@t3tools/vibe-app-server/errors"; import { VibeSettings } from "../settings.ts"; import { @@ -14,6 +19,8 @@ import { buildVibeSpawnInput, resolveVibeModelId, vibeAgentForRuntimeMode, + vibeModelWriteError, + VIBE_PROVIDER, } from "./appServerSupport.ts"; const decodeSettings = Schema.decodeSync(VibeSettings); @@ -92,6 +99,38 @@ describe("resolveVibeModelId", () => { }); }); +describe("vibeModelWriteError", () => { + it("shows the user that Vibe rejected the selected model, and hides other write failures", () => { + const failureMessage = (detail: string, code: string) => + makeProviderFailure({ + cause: Cause.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: VIBE_PROVIDER, + threadId: ThreadId.make("thread:vibe-model"), + providerThreadId: ProviderThreadId.make("provider-thread:vibe-model"), + runId: RunId.make("run:vibe-model"), + cause: vibeModelWriteError( + "mistral-large-4-0-3", + new VibeAppServerRequestError({ method: "config/model/write", code, detail }), + ), + }), + ), + }).message; + + expect( + failureMessage( + "Unknown model: mistral-large-4-0-3. Available: devstral-small, mistral-medium-3.5", + "invalid_params", + ), + ).toBe( + 'Vibe can\'t run the model "mistral-large-4-0-3" on your Mistral account right now. Choose another Vibe model, then retry.', + ); + expect( + failureMessage("The session's workspace could not be prepared", "conflict"), + ).not.toContain("mistral-large-4-0-3"); + }); +}); + describe("applyVibeHistoryPatch", () => { it("concatenates streamed `append` chunks instead of replacing the text", () => { let entry: Record = { content: [{ type: "text", text: "" }] }; diff --git a/packages/provider-vibe/src/server/appServerSupport.ts b/packages/provider-vibe/src/server/appServerSupport.ts index 3171a41e3e7c7..5f8034709de8c 100644 --- a/packages/provider-vibe/src/server/appServerSupport.ts +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -8,6 +8,7 @@ import type { VibeSettings } from "../settings.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as Scope from "effect/Scope"; @@ -101,6 +102,28 @@ export const DEFAULT_VIBE_MODEL = "mistral-medium-3.5"; /** Mistral Vibe's application rate-limit code, outside the JSON-RPC reserved range. */ export const MISTRAL_VIBE_RATE_LIMITED = -31001; +export class VibeModelUnavailableError extends Schema.TaggedError()( + "VibeModelUnavailableError", + { + model: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Vibe can't run the model "${this.model}" on your Mistral account right now. Choose another Vibe model, then retry.`; + } +} + +const isVibeRequestError = Schema.is(VibeError.VibeAppServerRequestError); + +/** Vibe's `config/model/write` rejection of a model the account does not offer (`_config_write.py`). */ +export const vibeModelWriteError = (model: string, cause: unknown): unknown => + isVibeRequestError(cause) && + cause.code === "invalid_params" && + cause.detail.startsWith("Unknown model:") + ? new VibeModelUnavailableError({ model, cause }) + : cause; + type VibeAppServerSettings = Pick< VibeSettings, "binaryPath" | "localModelEnabled" | "localModelBaseUrl" | "localModelId"