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..d16ec83ef41b5 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]: "Vibe", }; 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/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..c3ecc72eb9dfd --- /dev/null +++ b/packages/provider-vibe/src/client.ts @@ -0,0 +1,33 @@ +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: "Vibe", + featured: false, + 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-app-server 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..d9377faa18cf3 --- /dev/null +++ b/packages/provider-vibe/src/server/adapter.ts @@ -0,0 +1,1179 @@ +import { + NodeId, + ProviderInstanceId, + ProviderTurnId, + RunId, + RunAttemptId, + RuntimeRequestId, + type ThreadId, + type OrchestrationV2ConversationMessage, + type OrchestrationV2RuntimeRequest, + 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 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"; +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 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"; +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, + vibeAgentForRuntimeMode, + vibeModelWriteError, +} from "./appServerSupport.ts"; +import { vibeEffectSettled, vibeHistoryTurnItem } from "./historyItems.ts"; + +const VibeProviderCapabilitiesV2 = { + sessions: { + // A vibe-app-server connection streams events for only its last attached session. + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: true, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: true, + canForkThread: false, + 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) ?? "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; + /** 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>; + /** 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, + { readonly callbackId: string; readonly kind: "permission" | "user_input" } + >; +} + +export const makeVibeAdapterV2 = Effect.fn("makeVibeAdapterV2")(function* ( + options: VibeAdapterV2Options, +) { + const idAllocator = yield* IdAllocator.IdAllocatorV2; + 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: "Vibe app-server request failed.", + cause, + }); + + 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, + 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], { + forceKillAfter: "1 second", + ...(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"], + }, + }), + protocolError, + ); + yield* Effect.mapError(client.initialized(), protocolError); + + const events = yield* PubSub.unbounded(); + 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; + 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. + if (str(entry["type"]) !== "message" || role !== "assistant") return; + 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: "agent", + creationSource: "provider", + id: messageId, + threadId, + runId: state.currentRun?.runId ?? null, + nodeId: state.currentRun?.rootNodeId ?? null, + role: "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: "assistant_message" as const, + messageId, + text, + textOmitted: true 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 = input.providerSessionId; + 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, + currentRun: undefined, + entries: new Map(), + callbackByRequest: new Map(), + instructionsDelivered: { orchestration: false, device: false }, + }; + + 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.currentRun?.rootNodeId ?? NodeId.make(`vibe-root-${nativeTurnId}`), + runAttemptId: state.currentRun?.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/", ""); + // A user-cancelled turn is its own terminal status, not a failure. + const terminalStatus = + status === "failed" + ? "failed" + : status === "cancelled" + ? "cancelled" + : status === "interrupted" + ? "interrupted" + : "completed"; + yield* emit({ + type: "provider_turn.updated", + driver: VIBE_PROVIDER, + providerTurn: { + id: providerTurnId, + providerThreadId: state.thread.id, + 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, + 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 as "cancelled" | "completed" | "interrupted", + 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") { + const entry = asRecord(record["entry"]); + if (!entry || !state.thread) return; + const nativeItemId = str(entry["id"]) ?? `${method}:${yield* random.uuidV4()}`; + 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 = applyVibeHistoryPatch(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`. + 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: "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") { + const record = asRecord(params) ?? {}; + const filePath = str(record["path"]); + if (!filePath) { + return yield* new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "invalid_params", + detail: "path is required", + }); + } + // 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))); + const absolute = path.resolve(root, filePath); + const isInside = (candidate: string) => { + const relative = path.relative(root, candidate); + return !relative.startsWith("..") && !path.isAbsolute(relative); + }; + if (!isInside(absolute)) { + return yield* outsideWorkspace(); + } + // 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 a dangling symlink", + }); + } + if (!isInside(resolved)) { + return yield* outsideWorkspace(); + } + if (method === "clientTool/readTextFile") { + const content = yield* fileSystem.readFileString(resolved).pipe( + Effect.mapError( + (cause) => + new VibeAppServerError.VibeAppServerRequestError({ + method, + code: "internal_error", + detail: "read failed", + data: cause, + }), + ), + ); + return { 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(resolved, 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}`, + }); + } + 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( + Effect.provideService(onServerRequest(method, params), FileSystem.FileSystem, fileSystem), + Path.Path, + path, + ), + ); + + 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 mcpSession = yield* mcpSessions.read(ensureInput.threadId); + // 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, + 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, + } + : {}), + ...(getModelSelectionStringOptionValue(ensureInput.modelSelection, "thinking") !== + undefined + ? { + reasoningEffort: getModelSelectionStringOptionValue( + ensureInput.modelSelection, + "thinking", + ), + } + : {}), + }), + 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; + state.currentRun = { + runId: turnInput.runId, + runAttemptId: turnInput.attemptId, + rootNodeId: turnInput.rootNodeId, + }; + // A rejected model write must not silently run the turn with the previous model. + const modelAlias = resolveVibeModelId(turnInput.modelSelection.model); + yield* client + .configModelWrite({ + sessionId: nativeSessionId, + modelAlias, + reasoningEffort: getModelSelectionStringOptionValue(turnInput.modelSelection, "thinking"), + sessionOnly: true, + }) + .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({ + 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: promptText }] }], + }), + protocolError, + ); + if (mcpSession !== undefined) { + state.instructionsDelivered = { + orchestration: true, + device: state.instructionsDelivered.device || hasDeviceTools, + }; + } + 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 pending = state.callbackByRequest.get(responseInput.requestId); + const callbackId = pending?.callbackId ?? responseInput.requestId; + const requestKind = pending?.kind ?? "permission"; + // 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: output, + }), + 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: requestKind, + status: "resolved", + responseCapability: { type: "live", providerSessionId }, + createdAt: resolvedAt, + resolvedAt, + decision, + ...(responseInput.answers ? { answers: responseInput.answers } : {}), + } satisfies OrchestrationV2RuntimeRequest, + }); + }); + + // 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; + if (!nativeSessionId) { + return yield* new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: VIBE_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + }); + } + const historyEntries = yield* readAllHistoryEntries(nativeSessionId); + const turns = yield* Effect.mapError( + client.sessionTurnsList({ sessionId: nativeSessionId, limit: 500 }), + protocolError, + ); + const now = yield* DateTime.now; + const messages: OrchestrationV2ConversationMessage[] = []; + for (const entry of historyEntries) { + 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", + 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 === "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, + }); + } + // 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, + }); + } + // session/rewind takes a history entry id, not a turn id: resolve the input entry of the + // 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, + 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 }); + }); + + const forkThread: ProviderAdapter.ProviderAdapterV2SessionRuntime["forkThread"] = (forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: VIBE_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause: "Vibe forks go through T3's context handoff.", + }), + ); + + return { + instanceId: options.instanceId, + driver: VIBE_PROVIDER, + providerSessionId, + providerSession: state.session, + // 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({ + 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( + Effect.provideService( + Effect.provideService( + openSessionInner(input), + ChildProcessSpawner.ChildProcessSpawner, + spawner, + ), + FileSystem.FileSystem, + fileSystem, + ), + Path.Path, + path, + ); + }), + }); +}); + +export type VibeAdapterV2Env = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | 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 new file mode 100644 index 0000000000000..e7616fdf416e6 --- /dev/null +++ b/packages/provider-vibe/src/server/appServerSupport.test.ts @@ -0,0 +1,190 @@ +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 { + DEFAULT_VIBE_MODEL, + applyVibeHistoryPatch, + stripT3PromptInstructions, + vibeMcpServers, + VIBE_DEFAULT_BINARY, + buildVibeEnvironment, + buildVibeSpawnInput, + resolveVibeModelId, + vibeAgentForRuntimeMode, + vibeModelWriteError, + VIBE_PROVIDER, +} 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("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: "" }] }; + 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" }, + ]); + }); +}); + +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 new file mode 100644 index 0000000000000..5f8034709de8c --- /dev/null +++ b/packages/provider-vibe/src/server/appServerSupport.ts @@ -0,0 +1,301 @@ +import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; +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 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"; + +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"; + +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; +/** + * 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; +} + +/** 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. */ +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" +>; + +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 }; + } +} + +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], { + // 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 } : {}), + }), + ) + .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", + // 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"], + }, + }); + 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({ forceKillAfter: "1 second" }).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..67323da2e691e --- /dev/null +++ b/packages/provider-vibe/src/server/driver.ts @@ -0,0 +1,166 @@ +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: "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.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + 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/historyItems.test.ts b/packages/provider-vibe/src/server/historyItems.test.ts new file mode 100644 index 0000000000000..20cd8cef2979e --- /dev/null +++ b/packages/provider-vibe/src/server/historyItems.test.ts @@ -0,0 +1,111 @@ +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", + }, + ], + [ + "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); + }); + + 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..8edfd65895c6a --- /dev/null +++ b/packages/provider-vibe/src/server/historyItems.ts @@ -0,0 +1,154 @@ +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"]); + // 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); + 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 }), + }; +} 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..a72a8e0309c6f --- /dev/null +++ b/packages/provider-vibe/src/server/status.ts @@ -0,0 +1,266 @@ +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: "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 ?? "Vibe", + }; + case "missing_key": + case "unauthorized": + return { status: "unauthenticated", type: "vibe", label: "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: "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); + // 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( + { + 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, + // Probes must not mutate the user's persisted workspace trust. + trustWorkspace: false, + }, + }); + 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: "Vibe app server timed out while probing.", + } satisfies VibeProbeResult) + : option.value, + ), + Effect.catchCause((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: "Vibe app server probe failed.", + } 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..793f05f46c477 --- /dev/null +++ b/packages/provider-vibe/src/server/textGeneration.ts @@ -0,0 +1,154 @@ +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; + 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 + .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 turns[0]; + 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 }] }], + }); + const terminalTurn = yield* waitForTurnTerminal(client, sessionId); + if (terminalTurn === undefined || terminalTurn.status !== "completed") { + const error = asRecord(terminalTurn?.error); + return yield* new TextGenerationError({ + operation, + detail: `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); + 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) => + option._tag === "None" + ? Effect.fail(new TextGenerationError({ operation, detail: "Vibe request timed out." })) + : Effect.succeed(option.value), + ), + Effect.mapError((cause: VibeError.VibeAppServerError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Vibe app server request failed.", + cause, + }), + ), + ); + + if (!output) { + return yield* new TextGenerationError({ + operation, + detail: "Vibe returned empty output.", + }); + } + return yield* TextGenerationOperations.decodeJsonReply(request, "Vibe", output); + }, + (effect, request) => { + const { operation } = request; + return effect.pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Vibe text generation failed.", + cause, + }), + ), + ); + }, + ); + + return TextGenerationOperations.fromRunner( + "VibeTextGeneration", + TextGenerationOperations.withoutInvestigation("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..67d7942919f36 --- /dev/null +++ b/packages/provider-vibe/src/server/usageLimits.test.ts @@ -0,0 +1,48 @@ +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", + // November has 30 days; the length lets the UI show pace. + windowDurationMins: 30 * 24 * 60, + }); + }); + + 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]?.windowDurationMins).toBeUndefined(); + 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..1b5018e711072 --- /dev/null +++ b/packages/provider-vibe/src/server/usageLimits.ts @@ -0,0 +1,55 @@ +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, + 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. + */ +/** 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, +): 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 (pay-as-you-go)" : "Monthly", + usedPercent: clampPercent(budget.usagePercentage), + ...(budget.resetAt ? { resetsAt: budget.resetAt } : {}), + ...monthlyWindowDuration(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..2ef6f651b3a61 --- /dev/null +++ b/packages/vibe-app-server/src/client.test.ts @@ -0,0 +1,162 @@ +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(); + }); +}); + +/* 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`)); + +/** 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, +): Effect.Effect => + Effect.forever( + Effect.gen(function* () { + 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, { + 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* flushQueues(); + 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* 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"); + 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 new file mode 100644 index 0000000000000..9edaed5fc57cc --- /dev/null +++ b/packages/vibe-app-server/src/client.ts @@ -0,0 +1,628 @@ +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) { + 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, + page, + }); + }), + sessionTurnsList: Effect.fn("VibeAppServerClient.sessionTurnsList")(function* (input) { + 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) => + 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, + result: { + callbackId: input.callbackId, + output: 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(); + // 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, + 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..58aa6188e23d9 --- /dev/null +++ b/packages/vibe-app-server/src/protocol.ts @@ -0,0 +1,380 @@ +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"; +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 requestPubSub = yield* PubSub.unbounded(); + const notificationPubSub = yield* PubSub.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( + 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 = ( + 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( + Effect.asVoid(PubSub.publish(requestPubSub, 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( + Effect.asVoid(PubSub.publish(notificationPubSub, message)), + options.onNotification === undefined + ? Effect.void + : Effect.ignore(options.onNotification(message)), + ), + ); + } + 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; + 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 } : {}), + }); + // 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) => + 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.fromPubSub(requestPubSub), + incomingNotifications: Stream.fromPubSub(notificationPubSub), + 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",