diff --git a/.gitignore b/.gitignore index 8e1669c8115..8bee28270f2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ node_modules/ *.log .env* !.env.example + +# Android / Gradle build caches (regenerated per build) +.gradle/ diff --git a/apps/server/src/provider/Drivers/KimiDriver.ts b/apps/server/src/provider/Drivers/KimiDriver.ts new file mode 100644 index 00000000000..0eb48a1bd0a --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiDriver.ts @@ -0,0 +1,197 @@ +import { KimiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeKimiTextGeneration } from "../../textGeneration/KimiTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { resolveKimiBinaryPath } from "../acp/KimiAcpSupport.ts"; +import { makeKimiAdapter } from "../Layers/KimiAdapter.ts"; +import { + buildInitialKimiProviderSnapshot, + checkKimiProviderStatus, + enrichKimiSnapshot, +} from "../Layers/KimiProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +export const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type KimiDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: KIMI_DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const KimiDriver: ProviderDriver = { + driverKind: KIMI_DRIVER_KIND, + metadata: { + displayName: "Kimi Code", + supportsMultipleInstances: true, + }, + configSchema: KimiSettings, + defaultConfig: (): KimiSettings => decodeKimiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: KIMI_DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies KimiSettings; + const resolvedKimiCommand = yield* resolveKimiBinaryPath(effectiveConfig, processEnv); + // Thread the resolved command back through the config handed to the + // adapter, text generation, and status probe. When resolution found a + // concrete path (e.g. the ~/.kimi-code/bin fallback), downstream + // resolveKimiBinaryPath calls take the explicit-path fast path instead + // of rescanning PATH on every spawn. When resolution fell through to + // the bare "kimi" default, downstream calls keep re-resolving, so a + // later install is still picked up by the periodic status refresh. + const resolvedConfig = { + ...effectiveConfig, + binaryPath: resolvedKimiCommand, + } satisfies KimiSettings; + // Provider maintenance (`kimi upgrade`) is spawned from the server's own + // environment, not this instance's ProviderInstanceEnvironment. A bare + // "kimi" executable (found only via the instance-augmented PATH) would + // fail command-not-found there, so pin the maintenance command to an + // absolute path resolved with the instance environment while it is still + // in scope. If the CLI can't be resolved (not installed), fall back to + // the bare command — maintenance can't run in that state anyway. + const kimiUpdateExecutable = yield* resolveCommandPath(resolvedKimiCommand, { + env: processEnv, + }).pipe(Effect.catchTags({ CommandResolutionError: () => Effect.succeed(resolvedKimiCommand) })); + const update = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: KIMI_DRIVER_KIND, + packageName: null, + updateExecutable: kimiUpdateExecutable, + updateArgs: ["upgrade"], + updateLockKey: "kimi-code", + }), + ); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(update, { + binaryPath: resolvedKimiCommand, + env: processEnv, + }); + + const adapter = yield* makeKimiAdapter(resolvedConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeKimiTextGeneration(resolvedConfig, processEnv); + + const checkProvider = checkKimiProviderStatus(resolvedConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKimiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichKimiSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: KIMI_DRIVER_KIND, + instanceId, + detail: "Failed to build Kimi Code snapshot: " + (cause.message ?? String(cause)), + cause, + }), + ), + ); + + return { + instanceId, + driverKind: KIMI_DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 4dd38519c5a..fcc2d1f78c8 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1,6 +1,11 @@ /** * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. * + * Cursor-specific wiring on top of the provider-agnostic + * {@link makeStandardAcpAdapter} core: resume/mode handling, model selection, + * and Cursor's private ACP extension protocol registered through the base's + * generic extension hook. + * * @module CursorAdapterLive */ @@ -8,63 +13,19 @@ import { ApprovalRequestId, type CursorSettings, type ProviderOptionSelection, - EventId, - type ProviderApprovalDecision, type ProviderInteractionMode, - type ProviderRuntimeEvent, - type ProviderSession, type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, RuntimeRequestId, type RuntimeMode, - type ThreadId, - TurnId, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PubSub from "effect/PubSub"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as EffectAcpErrors from "effect-acp/errors"; -import type * as EffectAcpSchema from "effect-acp/schema"; +import type * as EffectAcpErrors from "effect-acp/errors"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, -} from "../Errors.ts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "../acp/AcpCoreRuntimeEvents.ts"; -import { - type AcpSessionMode, - type AcpSessionModeState, - parsePermissionRequest, -} from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { type AcpSessionMode, type AcpSessionModeState } from "../acp/AcpRuntimeModel.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { CursorAskQuestionRequest, @@ -76,29 +37,19 @@ import { } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +import { + makeStandardAcpAdapter, + type StandardAcpAdapterConfig, + type StandardAcpAdapterOptions, + type StandardAcpExtensionContext, +} from "./StandardAcpAdapter.ts"; -const PROVIDER = ProviderDriverKind.make("cursor"); -const CURSOR_RESUME_VERSION = 1 as const; +const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; const ACP_APPROVAL_MODE_ALIASES = ["ask"]; -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; -} - -export interface CursorAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; - /** - * Selections are honored when `modelSelection.instanceId` matches this value. - * Defaults to the legacy built-in instance id (`cursor`). - */ - readonly instanceId?: ProviderInstanceId; +export interface CursorAdapterLiveOptions extends StandardAcpAdapterOptions { /** * Optional per-session settings resolver. When provided the adapter yields * this effect at the start of every session and uses the result instead of @@ -113,70 +64,6 @@ export interface CursorAdapterLiveOptions { readonly resolveSettings?: Effect.Effect; } -interface PendingApproval { - readonly decision: Deferred.Deferred; - readonly kind: string | "unknown"; -} - -interface PendingUserInput { - readonly answers: Deferred.Deferred; -} - -interface CursorSessionContext { - readonly threadId: ThreadId; - session: ProviderSession; - readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - /** Number of sendTurn prompts currently in flight or being prepared. - * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ - promptsInFlight: number; - stopped: boolean; -} - -function settlePendingApprovalsAsCancelled( - pendingApprovals: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingApprovals.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { - discard: true, - }, - ); -} - -function settlePendingUserInputsAsEmptyAnswers( - pendingUserInputs: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingUserInputs.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), - { - discard: true, - }, - ); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseCursorResume(raw: unknown): { sessionId: string } | undefined { - if (!isRecord(raw)) return undefined; - if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; - if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; - return { sessionId: raw.sessionId.trim() }; -} - function normalizeModeSearchText(mode: AcpSessionMode): string { return [mode.id, mode.name, mode.description] .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -256,7 +143,7 @@ function applyRequestedSessionConfiguration(input: { } | undefined; readonly mapError: (context: { - readonly cause: import("effect-acp/errors").AcpError; + readonly cause: EffectAcpErrors.AcpError; readonly method: "session/set_config_option" | "session/set_mode"; }) => E; }): Effect.Effect { @@ -294,889 +181,125 @@ function applyRequestedSessionConfiguration(input: { }); } -function selectAutoApprovedPermissionOption( - request: EffectAcpSchema.RequestPermissionRequest, -): string | undefined { - const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); - if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { - return allowAlwaysOption.optionId.trim(); - } - - const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); - if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { - return allowOnceOption.optionId.trim(); - } - - return undefined; -} - -export function makeCursorAdapter( - cursorSettings: CursorSettings, - options?: CursorAdapterLiveOptions, -) { +/** + * Registers Cursor's private ACP extension protocol + * (`cursor/ask_question`, `cursor/create_plan`, `cursor/update_todos`) through + * the base adapter's generic extension hook. Behavior is identical to the + * inline registrations these methods historically lived beside. + */ +function registerCursorExtensions( + ctx: StandardAcpExtensionContext, +): Effect.Effect { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cursor"); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); - const crypto = yield* Crypto.Crypto; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); - - const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const runtimeEventPubSub = yield* PubSub.unbounded(); - - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "crypto/randomUUIDv4", - detail: "Failed to generate Cursor runtime identifier.", - cause, - }), - ), - ); - const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - const mapExtensionFailure = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to process Cursor ACP extension event.", - cause, - }), - ), - ); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const logNative = ( - threadId: ThreadId, - method: string, - payload: unknown, - _source: "acp.jsonrpc" | "acp.cursor.extension", - ) => - Effect.gen(function* () { - if (!nativeEventLogger) return; - const observedAt = yield* nowIso; - yield* nativeEventLogger.write( - { - observedAt, - event: { - id: yield* randomUUIDv4, - kind: "notification", - provider: PROVIDER, - createdAt: observedAt, - method, - threadId, - payload, - }, - }, - threadId, - ); - }); - - const emitPlanUpdate = ( - ctx: CursorSessionContext, - payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }, - rawPayload: unknown, - source: "acp.jsonrpc" | "acp.cursor.extension", - method: string, - ) => - Effect.gen(function* () { - const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; - if (ctx.lastPlanFingerprint === fingerprint) { - return; - } - ctx.lastPlanFingerprint = fingerprint; - yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload, - source, - method, - rawPayload, - }), - ); - }); - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), - ); - } - return Effect.succeed(ctx); - }; - - const stopSessionInternal = (ctx: CursorSessionContext) => - Effect.gen(function* () { - if (ctx.stopped) return; - ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); - if (ctx.notificationFiber) { - yield* Fiber.interrupt(ctx.notificationFiber); - } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); - sessions.delete(ctx.threadId); - yield* offerRuntimeEvent({ - type: "session.exited", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - payload: { exitKind: "graceful" }, - }); - }); - - const startSession: CursorAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, + yield* ctx.acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + ctx.mapExtensionFailure( Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } - - const cwd = path.resolve(input.cwd.trim()); - const cursorModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - let ctx!: CursorSessionContext; - - const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, - provider: PROVIDER, - threadId: input.threadId, - }); - - // Resolve the CursorSettings used to spawn the ACP child. Production - // leaves `options.resolveSettings` undefined so we use the value - // captured at adapter construction — per-instance isolation is - // enforced by the hydration layer rebuilding this adapter whenever - // its config changes. Tests set `resolveSettings` to pull the latest - // snapshot from `ServerSettingsService` so that mid-suite - // `updateSettings({ providers: { cursor: { binaryPath } } })` calls - // actually take effect when the next session spawns. - const effectiveCursorSettings = options?.resolveSettings - ? yield* options.resolveSettings - : cursorSettings; - - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeCursorAcpRuntime({ - cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/ask_question", - params, - "acp.cursor.extension", - ); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/ask_question", - payload: params, - }, - }); - const resolved = yield* Deferred.await(answers); - pendingUserInputs.delete(requestId); - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - payload: { answers: resolved }, - }); - return { answers: resolved }; - }), - ), - ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/create_plan", - params, - "acp.cursor.extension", - ); - yield* offerRuntimeEvent({ - type: "turn.proposed.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - payload: { planMarkdown: extractPlanMarkdown(params) }, - raw: { - source: "acp.cursor.extension", - method: "cursor/create_plan", - payload: params, - }, - }); - return { accepted: true } as const; - }), - ), - ); - yield* acp.handleExtNotification( - "cursor/update_todos", - CursorUpdateTodosRequest, - (params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/update_todos", - params, - "acp.cursor.extension", - ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ), - ); - yield* acp.handleRequestPermission((params) => - mapExtensionFailure( - Effect.gen(function* () { - yield* logNative( - input.threadId, - "session/request_permission", - params, - "acp.jsonrpc", - ); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, - }; - }), - ), - ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - yield* applyRequestedSessionConfiguration({ - runtime: acp, - runtimeMode: input.runtimeMode, - interactionMode: undefined, - modelSelection: cursorModelSelection, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), - }); - - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: cursorModelSelection?.model, - threadId: input.threadId, - resumeCursor: { - schemaVersion: CURSOR_RESUME_VERSION, - sessionId: started.sessionId, + yield* ctx.logNative("cursor/ask_question", params, "acp.cursor.extension"); + const requestId = ApprovalRequestId.make(yield* ctx.randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + ctx.pendingUserInputs.set(requestId, { answers }); + yield* ctx.offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* ctx.makeEventStamp()), + provider: ctx.provider, + threadId: ctx.threadId, + turnId: ctx.getActiveTurnId(), + requestId: runtimeRequestId, + payload: { questions: extractAskQuestions(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, }, - createdAt: now, - updatedAt: now, - }; - - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - pendingUserInputs, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - promptsInFlight: 0, - stopped: false, - }; - - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - switch (event._tag) { - case "EventStreamBarrier": - yield* Deferred.succeed(event.acknowledge, undefined); - return; - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* emitPlanUpdate( - ctx, - event.payload, - event.rawPayload, - "acp.jsonrpc", - "session/update", - ); - return; - case "ToolCallUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process Cursor runtime notification.", { cause }), - ), - Effect.forkChild, - ); - - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; - - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "Cursor ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); - - return session; - }).pipe(Effect.scoped), - ); - - const sendTurn: CursorAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent folds - // the new prompt into the ongoing work, so the active turn id is - // reused instead of opening a new turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - // Count this prompt immediately so a superseded in-flight prompt - // resolving from here on does not settle the turn; the matching - // decrement is the `ensuring` below. - ctx.promptsInFlight += 1; - - return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = resolveCursorAcpBaseModelId(model); - yield* applyRequestedSessionConfiguration({ - runtime: ctx.acp, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - modelSelection: - model === undefined - ? undefined - : { - model, - options: turnModelSelection?.options, - }, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), - }); - ctx.activeTurnId = turnId; - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: resolvedModel }, - }); - } - - const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); - } - if (input.attachments && input.attachments.length > 0) { - for (const attachment of input.attachments) { - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: cause.message, - cause, - }), - ), - ); - promptParts.push({ - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }); - } - } - - if (promptParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", - }); - } - - const result = yield* ctx.acp - .prompt({ - prompt: promptParts, - }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), - ), - ); - - const turnRecord = ctx.turns.find((turn) => turn.id === turnId); - if (turnRecord) { - turnRecord.items.push({ prompt: promptParts, result }); - } else { - ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - model: resolvedModel, - }; - - // Only the last remaining prompt settles the turn — a steer- - // superseded prompt resolving (usually cancelled) while another is - // in flight or pending must leave the merged turn running. - if (ctx.promptsInFlight === 1) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, - }, - }); - } - - return { - threadId: input.threadId, - turnId, - resumeCursor: ctx.session.resumeCursor, - }; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); - }), - ), - ); - }); - - const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); - }); - - const respondToRequest: CursorAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingApprovals.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending approval request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.decision, decision); - }); - - const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( - threadId, - requestId, - answers, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingUserInputs.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "cursor/ask_question", - detail: `Unknown pending user-input request: ${requestId}`, }); - } - yield* Deferred.succeed(pending.answers, answers); - }); - - const readThread: CursorAdapterShape["readThread"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - return { threadId, turns: ctx.turns }; - }); - - const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", + const resolved = yield* Deferred.await(answers); + ctx.pendingUserInputs.delete(requestId); + yield* ctx.offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* ctx.makeEventStamp()), + provider: ctx.provider, + threadId: ctx.threadId, + turnId: ctx.getActiveTurnId(), + requestId: runtimeRequestId, + payload: { answers: resolved }, }); - } - const nextLength = Math.max(0, ctx.turns.length - numTurns); - ctx.turns.splice(nextLength); - return { threadId, turns: ctx.turns }; - }); - - const stopSession: CursorAdapterShape["stopSession"] = (threadId) => - withThreadLock( - threadId, + return { answers: resolved }; + }), + ), + ); + yield* ctx.acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + ctx.mapExtensionFailure( Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); + yield* ctx.logNative("cursor/create_plan", params, "acp.cursor.extension"); + yield* ctx.offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* ctx.makeEventStamp()), + provider: ctx.provider, + threadId: ctx.threadId, + turnId: ctx.getActiveTurnId(), + payload: { planMarkdown: extractPlanMarkdown(params) }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true } as const; }), - ); - - const listSessions: CursorAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); - - const hasSession: CursorAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const c = sessions.get(threadId); - return c !== undefined && !c.stopped; - }); - - const stopAll: CursorAdapterShape["stopAll"] = () => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); - - yield* Effect.addFinalizer(() => - Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( - Effect.catch((cause) => - Effect.logError("Failed to emit Cursor session shutdown event.", { cause }), - ), - Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), - Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), ), ); + yield* ctx.acp.handleExtNotification( + "cursor/update_todos", + CursorUpdateTodosRequest, + (params) => + ctx.mapExtensionFailure( + Effect.gen(function* () { + yield* ctx.logNative("cursor/update_todos", params, "acp.cursor.extension"); + yield* ctx.emitActiveSessionPlanUpdate( + extractTodosAsPlan(params), + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + }), + ), + ); + }); +} - const streamEvents = Stream.fromPubSub(runtimeEventPubSub); +export function makeCursorAdapter( + cursorSettings: CursorSettings, + options?: CursorAdapterLiveOptions, +) { + // Production captures per-instance settings at adapter construction. + // Tests may resolve the latest settings so mid-suite updates apply to + // the next spawned session. + const makeRuntime: StandardAcpAdapterConfig["makeRuntime"] = (input) => + options?.resolveSettings + ? options.resolveSettings.pipe( + Effect.flatMap((resolvedSettings) => + makeCursorAcpRuntime({ + ...input, + cursorSettings: resolvedSettings, + }), + ), + ) + : makeCursorAcpRuntime({ + ...input, + cursorSettings, + }); - return { - provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - streamEvents, - } satisfies CursorAdapterShape; - }); + return makeStandardAcpAdapter( + { + provider: CURSOR_PROVIDER, + defaultInstanceId: ProviderInstanceId.make("cursor"), + displayName: "Cursor", + registerExtensions: registerCursorExtensions, + makeRuntime, + applySessionConfiguration: applyRequestedSessionConfiguration, + resolveBaseModelId: resolveCursorAcpBaseModelId, + }, + options, + ).pipe(Effect.map((adapter): CursorAdapterShape => adapter)); } diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 33f61ad97f6..be24ea0b240 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -6,13 +6,9 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { causeErrorTag } from "@t3tools/shared/observability"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -20,16 +16,15 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, - isCommandMissingCause, - parseGenericCliVersion, providerModelsFromSettings, spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { type ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { - enrichProviderSnapshotWithVersionAdvisory, - type ProviderMaintenanceCapabilities, -} from "../providerMaintenance.ts"; + enrichCliProviderSnapshotAdvisory, + runCliProviderStatusProbe, +} from "../providerStatusProbe.ts"; import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; const GROK_PRESENTATION = { @@ -192,129 +187,47 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( - Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionResult)) { - const error = versionResult.failure; - yield* Effect.logWarning("Grok CLI health check failed.", { - errorTag: error._tag, - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Grok CLI (`grok`) is not installed or not on PATH." - : "Failed to execute Grok CLI health check.", - }, - }); - } - - if (Option.isNone(versionResult.success)) { - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but timed out while running `grok --version`.", - }, - }); - } - - const versionOutput = versionResult.success.value; - const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); - if (versionOutput.code !== 0) { - yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { - exitCode: versionOutput.code, - stdoutLength: versionOutput.stdout.length, - stderrLength: versionOutput.stderr.length, - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but failed to run.", - }, - }); - } - - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.exit, - ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Grok ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", - }, - }); - } - if (Option.isNone(discoveryExit.value)) { - yield* Effect.logWarning( - `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - ); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - }, - }); - } - const discoveredModels = discoveryExit.value.value; - const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) - : fallbackModels; - - return buildServerProvider({ + return yield* runCliProviderStatusProbe({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, - models, - probe: { - installed: true, - version, - status: "ready", - auth: { status: "unknown" }, + fallbackModels, + versionProbeTimeoutMs: VERSION_PROBE_TIMEOUT_MS, + discoveryTimeoutMs: GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS, + runVersionCommand: runGrokVersionCommand(grokSettings, environment), + discoverModels: discoverGrokModelsViaAcp(grokSettings, environment), + messages: { + disabled: "Grok is disabled in T3 Code settings.", + commandMissing: "Grok CLI (`grok`) is not installed or not on PATH.", + healthCheckFailed: "Failed to execute Grok CLI health check.", + versionProbeTimeout: "Grok CLI is installed but timed out while running `grok --version`.", + nonZeroExit: "Grok CLI is installed but failed to run.", + discoveryFailed: + "Grok CLI is installed but ACP startup failed. Check server logs for details.", + discoveryTimeout: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, }, + logMessages: { + healthCheckFailed: "Grok CLI health check failed.", + nonZeroExit: "Grok CLI version probe exited with a non-zero status.", + discoveryFailed: "Grok ACP model discovery failed", + discoveryTimeout: `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + buildDiscoveredSnapshot: ({ version, discoveredModels }) => + buildServerProvider({ + presentation: GROK_PRESENTATION, + enabled: grokSettings.enabled, + checkedAt, + models: + discoveredModels.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + : fallbackModels, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }), }); }); @@ -324,19 +237,12 @@ export const enrichGrokSnapshot = (input: { readonly enableProviderUpdateChecks?: boolean; readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { - const { snapshot, publishSnapshot } = input; - - return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { +}): Effect.Effect => + enrichCliProviderSnapshotAdvisory({ + snapshot: input.snapshot, + maintenanceCapabilities: input.maintenanceCapabilities, enableProviderUpdateChecks: input.enableProviderUpdateChecks, - }).pipe( - Effect.provideService(HttpClient.HttpClient, input.httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - Effect.catchCause((cause) => - Effect.logWarning("Grok version advisory enrichment failed", { - errorTag: causeErrorTag(cause), - }), - ), - Effect.asVoid, - ); -}; + publishSnapshot: input.publishSnapshot, + httpClient: input.httpClient, + warningLogMessage: "Grok version advisory enrichment failed", + }); diff --git a/apps/server/src/provider/Layers/KimiAdapter.test.ts b/apps/server/src/provider/Layers/KimiAdapter.test.ts new file mode 100644 index 00000000000..e3c9716181e --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.test.ts @@ -0,0 +1,48 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { KimiSettings, ProviderDriverKind, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../../config.ts"; +import { makeKimiAdapter } from "./KimiAdapter.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +const kimiAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-kimi-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +it.layer(kimiAdapterTestLayer)("KimiAdapter", (it) => { + it.effect("constructs a standards-only ACP adapter for the kimi provider", () => + Effect.gen(function* () { + const adapter = yield* makeKimiAdapter( + decodeKimiSettings({ binaryPath: "/unused/kimi", homePath: "" }), + ); + + expect(adapter.provider).toBe(ProviderDriverKind.make("kimi")); + expect(adapter.capabilities).toEqual({ sessionModelSwitch: "in-session" }); + expect(yield* adapter.listSessions()).toEqual([]); + }), + ); + + it.effect("rejects a start request addressed to another provider before spawning", () => + Effect.gen(function* () { + const adapter = yield* makeKimiAdapter( + decodeKimiSettings({ binaryPath: "/unused/kimi", homePath: "" }), + { instanceId: ProviderInstanceId.make("kimi") }, + ); + const error = yield* Effect.flip( + adapter.startSession({ + threadId: ThreadId.make("kimi-wrong-provider"), + provider: ProviderDriverKind.make("cursor"), + runtimeMode: "approval-required", + cwd: process.cwd(), + }), + ); + + expect(error._tag).toBe("ProviderAdapterValidationError"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiAdapter.ts b/apps/server/src/provider/Layers/KimiAdapter.ts new file mode 100644 index 00000000000..2f9dcc5113f --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -0,0 +1,113 @@ +import { + type KimiSettings, + type ProviderInteractionMode, + type ProviderOptionSelection, + ProviderDriverKind, + ProviderInstanceId, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + applyKimiAcpModeSelection, + applyKimiAcpModelSelection, + currentKimiModelIdFromConfigOptions, + isKimiModelCatalogEmpty, + makeKimiAcpRuntime, + makeKimiAuthRequiredError, + resolveKimiAcpBaseModelId, +} from "../acp/KimiAcpSupport.ts"; +import type { KimiAdapterShape } from "../Services/KimiAdapter.ts"; +import { makeStandardAcpAdapter, type StandardAcpAdapterConfig } from "./StandardAcpAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("kimi"); + +export interface KimiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +function applyKimiSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + // Kimi has no session/set_mode RPC: both model and mode changes go + // through session/set_config_option, so that is the only method tag + // this adapter can ever report. + readonly method: "session/set_config_option"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + // A logged-out Kimi CLI still creates sessions but reports an empty model + // catalog. Detect that up front — regardless of whether this request + // carries a model selection — so an unauthenticated session fails with the + // "run kimi login" message at session start instead of slipping through to + // the mode write (an opaque config error) or being accepted signed-out. + const configOptions = yield* input.runtime.getConfigOptions; + if (isKimiModelCatalogEmpty(configOptions)) { + return yield* Effect.fail( + input.mapError({ + cause: makeKimiAuthRequiredError(), + method: "session/set_config_option", + }), + ); + } + + if (input.modelSelection) { + yield* applyKimiAcpModelSelection({ + runtime: input.runtime, + currentModelId: currentKimiModelIdFromConfigOptions(configOptions), + requestedModelId: resolveKimiAcpBaseModelId(input.modelSelection.model), + mapError: (cause) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + yield* applyKimiAcpModeSelection({ + runtime: input.runtime, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + mapError: (cause) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + }); +} + +export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapterLiveOptions) { + const makeRuntime: StandardAcpAdapterConfig["makeRuntime"] = (input) => + makeKimiAcpRuntime({ + ...input, + kimiSettings, + }); + + return makeStandardAcpAdapter( + { + provider: PROVIDER, + defaultInstanceId: ProviderInstanceId.make("kimi"), + displayName: "Kimi Code", + makeRuntime, + applySessionConfiguration: applyKimiSessionConfiguration, + resolveBaseModelId: resolveKimiAcpBaseModelId, + }, + options, + ).pipe(Effect.map((adapter): KimiAdapterShape => adapter)); +} diff --git a/apps/server/src/provider/Layers/KimiProvider.test.ts b/apps/server/src/provider/Layers/KimiProvider.test.ts new file mode 100644 index 00000000000..6856a030ba7 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.test.ts @@ -0,0 +1,149 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { KimiSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + buildInitialKimiProviderSnapshot, + buildKimiDiscoveredModelsFromConfigOptions, + buildKimiDiscoveredProviderSnapshot, + checkKimiProviderStatus, +} from "./KimiProvider.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +describe("buildInitialKimiProviderSnapshot", () => { + it.effect("builds a disabled snapshot with the built-in fallback models", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot( + decodeKimiSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + }), + ); + + it.effect("builds a checking snapshot for enabled Kimi", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot(decodeKimiSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Checking Kimi Code"); + }), + ); +}); + +describe("buildKimiDiscoveredModelsFromConfigOptions", () => { + it("reads and deduplicates Kimi model select options", () => { + const models = buildKimiDiscoveredModelsFromConfigOptions([ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "kimi-for-coding", + options: [ + { value: "kimi-for-coding", name: "Kimi for Coding" }, + { value: "kimi-for-coding", name: "Duplicate" }, + { value: "kimi-for-coding-highspeed", name: "Kimi Highspeed" }, + ], + }, + ]); + + expect(models.map((model) => [model.slug, model.name])).toEqual([ + ["kimi-for-coding", "Kimi for Coding"], + ["kimi-for-coding-highspeed", "Kimi Highspeed"], + ]); + }); + + it("returns no models for the logged-out empty model select", () => { + expect( + buildKimiDiscoveredModelsFromConfigOptions([ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "", + options: [], + }, + ]), + ).toEqual([]); + }); +}); + +describe("buildKimiDiscoveredProviderSnapshot", () => { + const fallbackModels = [ + { slug: "kimi-for-coding", name: "Kimi for Coding", isCustom: false, capabilities: {} }, + ] as const; + const baseInput = { + kimiSettings: decodeKimiSettings({ enabled: true }), + checkedAt: "2026-07-17T00:00:00.000Z", + fallbackModels, + version: "0.26.0", + }; + + it("reports ready/authenticated when models were discovered", () => { + const snapshot = buildKimiDiscoveredProviderSnapshot({ + ...baseInput, + discovery: { + models: [ + { slug: "kimi-for-coding", name: "Kimi for Coding", isCustom: false, capabilities: {} }, + ], + catalogEmpty: false, + }, + }); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + }); + + it("reports unauthenticated when the model catalog is empty (signed out)", () => { + const snapshot = buildKimiDiscoveredProviderSnapshot({ + ...baseInput, + discovery: { models: [], catalogEmpty: true }, + }); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toMatch(/kimi login/); + }); + + it("reports a discovery error, not an auth failure, when there is no model option", () => { + const snapshot = buildKimiDiscoveredProviderSnapshot({ + ...baseInput, + discovery: { models: [], catalogEmpty: false }, + }); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).not.toMatch(/kimi login/); + expect(snapshot.message).toMatch(/incompatible|misconfigured|no models/); + }); +}); + +it.layer(NodeServices.layer)("checkKimiProviderStatus", (it) => { + it.effect("reports a missing configured Kimi binary without throwing", () => + Effect.gen(function* () { + const snapshot = yield* checkKimiProviderStatus( + decodeKimiSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/kimi-binary", + }), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiProvider.ts b/apps/server/src/provider/Layers/KimiProvider.ts new file mode 100644 index 00000000000..aa870953e20 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.ts @@ -0,0 +1,360 @@ +import { + type KimiSettings, + type ModelCapabilities, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { + getKimiAcpModelOptions, + isKimiAuthRequiredAcpError, + isKimiModelCatalogEmpty, + KIMI_AUTH_REQUIRED_MESSAGE, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, + resolveKimiBinaryPath, +} from "../acp/KimiAcpSupport.ts"; +import { type ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + buildServerProvider, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichCliProviderSnapshotAdvisory, + runCliProviderStatusProbe, +} from "../providerStatusProbe.ts"; + +const KIMI_PRESENTATION = { + displayName: "Kimi Code", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const PROVIDER = ProviderDriverKind.make("kimi"); +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +// The Kimi CLI is a large single binary; cold starts on Windows (first spawn +// after boot, antivirus scanning) can far exceed the ~1s warm-path latency. +const VERSION_PROBE_TIMEOUT_MS = 15_000; +const KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +// Shown when the ACP session carried no "model" config option at all (as +// opposed to an empty option list, which is the signed-out signal). This +// points at an incompatible or malformed CLI rather than an auth problem, so +// telling the user to "run kimi login" would only mislead them. +const KIMI_MODELS_UNAVAILABLE_MESSAGE = + "Kimi Code CLI is installed but returned no models. The installed CLI may be incompatible or misconfigured; check server logs for details."; + +export const KIMI_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "kimi-for-coding", + name: "Kimi for Coding", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "kimi-for-coding-highspeed", + name: "Kimi for Coding Highspeed", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialKimiProviderSnapshot( + kimiSettings: KimiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = kimiModelsFromSettings(kimiSettings.customModels); + + if (!kimiSettings.enabled) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi Code is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Kimi Code CLI availability...", + }, + }); + }); +} + +function kimiModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = KIMI_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings( + builtInModels, + PROVIDER, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +export function buildKimiDiscoveredModelsFromConfigOptions( + configOptions: Parameters[0], +): ReadonlyArray { + const seen = new Set(); + return getKimiAcpModelOptions(configOptions).flatMap((model) => { + const slug = resolveKimiAcpBaseModelId(model.value); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name: model.name || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +export interface KimiAcpDiscoveryResult { + readonly models: ReadonlyArray; + /** + * True when the CLI reported a "model" config option whose option list is + * empty — Kimi's signed-out signal. Distinguished from a response that + * carries no "model" option at all (an incompatible or malformed CLI), which + * also yields zero models but is not an authentication problem. + */ + readonly catalogEmpty: boolean; +} + +/** + * Map an ACP model-discovery result to a terminal provider snapshot. The three + * outcomes are kept distinct on purpose: + * - models present → ready / authenticated + * - empty catalog → unauthenticated ("run kimi login") + * - no model option → discovery error (incompatible/malformed CLI) + * + * Collapsing the last two — as a plain `models.length === 0` check would — tells + * users to log in even when authentication is fine, masking the real fault. + */ +export function buildKimiDiscoveredProviderSnapshot(input: { + readonly kimiSettings: KimiSettings; + readonly checkedAt: string; + readonly fallbackModels: ReadonlyArray; + readonly version: string | null; + readonly discovery: KimiAcpDiscoveryResult; +}): ServerProviderDraft { + const { kimiSettings, checkedAt, fallbackModels, version, discovery } = input; + + if (discovery.models.length > 0) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: kimiModelsFromSettings(kimiSettings.customModels, discovery.models), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); + } + + if (discovery.catalogEmpty) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unauthenticated" }, + message: KIMI_AUTH_REQUIRED_MESSAGE, + }, + }); + } + + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: KIMI_MODELS_UNAVAILABLE_MESSAGE, + }, + }); +} + +export const discoverKimiModelsViaAcp = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect< + KimiAcpDiscoveryResult, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeKimiAcpRuntime({ + kimiSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + yield* acp.start(); + const configOptions = yield* acp.getConfigOptions; + return { + models: buildKimiDiscoveredModelsFromConfigOptions(configOptions), + catalogEmpty: isKimiModelCatalogEmpty(configOptions), + }; + }).pipe( + Effect.scoped, + // A signed-out CLI may reject the ACP handshake with an auth-required error + // before the (empty) catalog can be read. Map that to the same signed-out + // result so the probe surfaces "run kimi login" instead of an opaque ACP + // startup failure. + Effect.catchIf(isKimiAuthRequiredAcpError, () => + Effect.succeed({ models: [], catalogEmpty: true } satisfies KimiAcpDiscoveryResult), + ), + ); + +const runKimiVersionCommand = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = yield* resolveKimiBinaryPath(kimiSettings, environment); + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkKimiProviderStatus = Effect.fn("checkKimiProviderStatus")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = kimiModelsFromSettings(kimiSettings.customModels); + + if (!kimiSettings.enabled) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi Code is disabled in T3 Code settings.", + }, + }); + } + + return yield* runCliProviderStatusProbe({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + fallbackModels, + versionProbeTimeoutMs: VERSION_PROBE_TIMEOUT_MS, + discoveryTimeoutMs: KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS, + runVersionCommand: runKimiVersionCommand(kimiSettings, environment), + discoverModels: discoverKimiModelsViaAcp(kimiSettings, environment), + messages: { + disabled: "Kimi Code is disabled in T3 Code settings.", + commandMissing: "Kimi Code CLI command kimi is not installed or not on PATH.", + healthCheckFailed: "Failed to execute Kimi Code CLI health check.", + versionProbeTimeout: "Kimi Code CLI is installed but timed out while running kimi --version.", + nonZeroExit: "Kimi Code CLI is installed but failed to run.", + discoveryFailed: + "Kimi Code CLI is installed but ACP startup failed. Check server logs for details.", + discoveryTimeout: + "Kimi Code CLI is installed but ACP startup timed out after " + + KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS + + "ms.", + }, + logMessages: { + healthCheckFailed: "Kimi Code CLI health check failed.", + nonZeroExit: "Kimi Code CLI version probe exited with a non-zero status.", + discoveryFailed: "Kimi ACP model discovery failed", + discoveryTimeout: + "Kimi ACP model discovery timed out after " + KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS + "ms.", + }, + buildDiscoveredSnapshot: ({ version, discoveredModels }) => + buildKimiDiscoveredProviderSnapshot({ + kimiSettings, + checkedAt, + fallbackModels, + version, + discovery: discoveredModels, + }), + }); +}); + +export const enrichKimiSnapshot = (input: { + readonly settings: KimiSettings; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => + enrichCliProviderSnapshotAdvisory({ + snapshot: input.snapshot, + maintenanceCapabilities: input.maintenanceCapabilities, + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + publishSnapshot: input.publishSnapshot, + httpClient: input.httpClient, + warningLogMessage: "Kimi version advisory enrichment failed", + skip: !input.settings.enabled || input.snapshot.auth.status === "unauthenticated", + }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 73390450efa..345a8b701b9 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `kimi`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `kimi` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -29,6 +29,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type KimiSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -44,6 +45,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; +import { KimiDriver } from "../Drivers/KimiDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -89,6 +91,14 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeKimiConfig = (overrides: Partial): KimiSettings => ({ + enabled: false, + binaryPath: "kimi", + homePath: "", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -257,12 +267,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); + const kimiId = ProviderInstanceId.make("kimi_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); + const kimiDriverKind = ProviderDriverKind.make("kimi"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -293,6 +305,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [kimiId]: { + driver: kimiDriverKind, + displayName: "Kimi Code", + enabled: false, + config: makeKimiConfig({}), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -302,7 +320,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, KimiDriver, OpenCodeDriver], configMap, }); @@ -312,9 +330,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, kimiId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -324,16 +342,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); + const kimi = yield* registry.getInstance(kimiId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); + expect(kimi?.driverKind).toBe(kimiDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); + expect(kimi?.displayName).toBe("Kimi Code"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -346,6 +367,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.adapter, cursor!.adapter, grok!.adapter, + kimi!.adapter, openCode!.adapter, ]; expect(new Set(adapters).size).toBe(adapters.length); @@ -354,6 +376,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.textGeneration, cursor!.textGeneration, grok!.textGeneration, + kimi!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); @@ -362,6 +385,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { claude!.snapshot, cursor!.snapshot, grok!.snapshot, + kimi!.snapshot, openCode!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -398,6 +422,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(grokSnapshot.enabled).toBe(false); expect(grokSnapshot.continuation?.groupKey).toBe(`${grokDriverKind}:instance:${grokId}`); + const kimiSnapshot = yield* kimi!.snapshot.getSnapshot; + expect(kimiSnapshot.instanceId).toBe(kimiId); + expect(kimiSnapshot.driver).toBe(kimiDriverKind); + expect(kimiSnapshot.enabled).toBe(false); + expect(kimiSnapshot.continuation?.groupKey).toBe(`${kimiDriverKind}:instance:${kimiId}`); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..623090ae38c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1076,6 +1076,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + kimi: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1187,6 +1188,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + kimi: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1300,6 +1302,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + kimi: { enabled: false }, opencode: { enabled: false }, }, providerInstances: { @@ -1369,6 +1372,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + kimi: { + enabled: false, + }, }, }), ), @@ -1437,6 +1443,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "kimi", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Layers/StandardAcpAdapter.ts b/apps/server/src/provider/Layers/StandardAcpAdapter.ts new file mode 100644 index 00000000000..eaf621834f6 --- /dev/null +++ b/apps/server/src/provider/Layers/StandardAcpAdapter.ts @@ -0,0 +1,1084 @@ +/** + * StandardAcpAdapter — the provider-agnostic ACP adapter core. + * + * Hosts `makeStandardAcpAdapter`, the shared session/turn/approval machinery + * every ACP-backed provider (Cursor, Kimi, …) is built on. Provider-specific + * behavior is supplied through {@link StandardAcpAdapterConfig}; drivers that + * speak a private ACP extension protocol register their handlers through the + * optional {@link StandardAcpAdapterConfig.registerExtensions} hook. + * + * @module StandardAcpAdapter + */ + +import { + ApprovalRequestId, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, + type AcpAdapterRawSource, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +/** + * Resume-cursor schema version shared by every standard ACP provider. The + * resume cursor only carries the agent's opaque `sessionId`, so the exact + * shape is provider-neutral. + */ +const STANDARD_ACP_RESUME_VERSION = 1 as const; + +type StandardAcpAdapterShape = ProviderAdapterShape; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface StandardAcpAdapterOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to `config.defaultInstanceId`. + */ + readonly instanceId?: ProviderInstanceId; +} + +/** + * Provider-specific session configuration applier. Called at session start and + * on every turn to reconcile the requested model/mode against the live ACP + * runtime. The `method` tag flows back into {@link mapAcpToAdapterError} so the + * base reports the failing RPC accurately. + */ +export type StandardAcpApplySessionConfiguration = (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}) => Effect.Effect; + +export interface StandardAcpAdapterConfig { + readonly provider: ProviderDriverKind; + readonly defaultInstanceId: ProviderInstanceId; + readonly displayName: string; + /** + * Optional hook for drivers that speak a private ACP extension protocol + * (e.g. Cursor's `cursor/ask_question` family). It runs once during session + * startup, at the same point in the lifecycle where the base registers its + * `session/request_permission` handler, and registers extension handlers via + * the supplied {@link StandardAcpExtensionContext}. Drivers with no private + * extensions simply omit it. + */ + readonly registerExtensions?: ( + ctx: StandardAcpExtensionContext, + ) => Effect.Effect; + readonly makeRuntime: ( + input: Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" + > & { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly environment?: NodeJS.ProcessEnv; + }, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | FileSystem.FileSystem | Path.Path | Scope.Scope + >; + readonly applySessionConfiguration: StandardAcpApplySessionConfiguration; + readonly resolveBaseModelId: (model: string | null | undefined) => string; +} + +/** Plan payload shape emitted through {@link StandardAcpExtensionContext.emitActiveSessionPlanUpdate}. */ +interface StandardAcpPlanUpdatePayload { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; +} + +/** + * Everything a provider's extension hook needs to bridge private ACP extension + * methods into the canonical runtime event stream. Modeled on exactly what the + * base itself uses for its permission handler plus the pending-user-input map, + * so extension handlers stay behavior-identical to inline base registrations. + */ +export interface StandardAcpExtensionContext { + /** The live ACP session runtime — use `handleExtRequest`/`handleExtNotification`. */ + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + /** Pending structured user-input requests keyed by request id. */ + readonly pendingUserInputs: Map; + /** Generates a fresh v4 UUID mapped into a provider request error on failure. */ + readonly randomUUIDv4: Effect.Effect; + /** Stamps a runtime event with a fresh event id and ISO timestamp. */ + readonly makeEventStamp: () => Effect.Effect< + { readonly eventId: EventId; readonly createdAt: string }, + ProviderAdapterRequestError + >; + /** Appends a native protocol event to the native NDJSON log for this thread. */ + readonly logNative: ( + method: string, + payload: unknown, + source: AcpAdapterRawSource, + ) => Effect.Effect; + /** Publishes a canonical runtime event to subscribers. */ + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + /** Wraps an extension handler so any failure surfaces as an `AcpError`. */ + readonly mapExtensionFailure: ( + effect: Effect.Effect, + ) => Effect.Effect; + /** Current active turn id, or `undefined` before the first turn. */ + readonly getActiveTurnId: () => TurnId | undefined; + /** + * Emits a deduplicated plan update for the active session. No-ops when the + * session context has not been assigned yet (matching the base's own guard). + */ + readonly emitActiveSessionPlanUpdate: ( + payload: StandardAcpPlanUpdatePayload, + rawPayload: unknown, + source: AcpAdapterRawSource, + method: string, + ) => Effect.Effect; +} + +export interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +export interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface StandardAcpSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseAcpResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== STANDARD_ACP_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makeStandardAcpAdapter( + config: StandardAcpAdapterConfig, + options?: StandardAcpAdapterOptions, +) { + return Effect.gen(function* () { + const PROVIDER = config.provider; + const boundInstanceId = options?.instanceId ?? config.defaultInstanceId; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate " + config.displayName + " runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + // Extension-handler failures surface as a transport error. Derived from the + // display name so Cursor's historical string stays byte-identical. + const extensionFailureDetail = `Failed to process ${config.displayName} ACP extension event.`; + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: extensionFailureDetail, + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: AcpAdapterRawSource, + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: StandardAcpSessionContext, + payload: StandardAcpPlanUpdatePayload, + rawPayload: unknown, + source: AcpAdapterRawSource, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: StandardAcpSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: StandardAcpAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const providerModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: StandardAcpSessionContext; + + const resumeSessionId = parseAcpResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Driver-specific spawn configuration is captured by `makeRuntime`. + // Instance hydration rebuilds the adapter whenever persisted config + // changes; drivers may additionally resolve settings at spawn time. + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* config + .makeRuntime({ + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }) + .pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const extensionContext: StandardAcpExtensionContext = { + acp, + provider: PROVIDER, + threadId: input.threadId, + pendingUserInputs, + randomUUIDv4, + makeEventStamp, + offerRuntimeEvent, + mapExtensionFailure, + logNative: (method, payload, source) => + logNative(input.threadId, method, payload, source), + getActiveTurnId: () => ctx?.activeTurnId, + emitActiveSessionPlanUpdate: (payload, rawPayload, source, method) => + ctx ? emitPlanUpdate(ctx, payload, rawPayload, source, method) : Effect.void, + }; + + const started = yield* Effect.gen(function* () { + if (config.registerExtensions) { + yield* config.registerExtensions(extensionContext); + } + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* config.applySessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: providerModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: providerModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: STANDARD_ACP_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError( + "Failed to process " + config.displayName + " runtime notification.", + { cause }, + ), + ), + Effect.forkChild, + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: config.displayName + " ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: StandardAcpAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = config.resolveBaseModelId(model); + yield* config.applySessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: StandardAcpAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: StandardAcpAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: StandardAcpAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: StandardAcpAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: StandardAcpAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: StandardAcpAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: StandardAcpAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: StandardAcpAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: StandardAcpAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit " + config.displayName + " session shutdown event.", { + cause, + }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies StandardAcpAdapterShape; + }); +} diff --git a/apps/server/src/provider/Services/KimiAdapter.ts b/apps/server/src/provider/Services/KimiAdapter.ts new file mode 100644 index 00000000000..6a2c5cdd4a7 --- /dev/null +++ b/apps/server/src/provider/Services/KimiAdapter.ts @@ -0,0 +1,16 @@ +/** + * KimiAdapter — shape type for the Kimi Code provider adapter. + * + * The driver model ({@link ../Drivers/KimiDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module KimiAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * KimiAdapterShape — per-instance Kimi Code adapter contract. + */ +export interface KimiAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index c93e61dc37b..65838180c5b 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -14,7 +14,7 @@ import { import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; -type AcpAdapterRawSource = Extract< +export type AcpAdapterRawSource = Extract< RuntimeEventRawSource, "acp.jsonrpc" | `acp.${string}.extension` >; diff --git a/apps/server/src/provider/acp/AcpModelSelection.ts b/apps/server/src/provider/acp/AcpModelSelection.ts new file mode 100644 index 00000000000..e24a352dc80 --- /dev/null +++ b/apps/server/src/provider/acp/AcpModelSelection.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +/** + * Switch the session model only when the requested id differs from the + * current one. Drivers differ in how a model switch is written (Grok uses + * the native session/set_model RPC, Kimi writes the "model" config option), + * so the setter is injected while the change-detection rule stays shared. + */ +export function applyAcpModelSelectionIfChanged(input: { + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly setModel: (modelId: string) => Effect.Effect; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel || input.requestedModelId === undefined) { + return Effect.succeed(input.currentModelId); + } + const requestedModelId = input.requestedModelId; + return input + .setModel(requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(requestedModelId)); +} diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e..70eca5e509c 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -8,6 +8,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { applyAcpModelSelectionIfChanged } from "./AcpModelSelection.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; @@ -97,12 +98,10 @@ export function applyGrokAcpModelSelection(input: { readonly requestedModelId: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const shouldSwitchModel = - input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { - return Effect.succeed(input.currentModelId); - } - return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + return applyAcpModelSelectionIfChanged({ + currentModelId: input.currentModelId, + requestedModelId: input.requestedModelId, + setModel: (modelId) => input.runtime.setSessionModel(modelId), + mapError: input.mapError, + }); } diff --git a/apps/server/src/provider/acp/KimiAcpSupport.test.ts b/apps/server/src/provider/acp/KimiAcpSupport.test.ts new file mode 100644 index 00000000000..b60923883e5 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.test.ts @@ -0,0 +1,217 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + applyKimiAcpModeSelection, + applyKimiAcpModelSelection, + buildKimiAcpSpawnInput, + currentKimiModelIdFromConfigOptions, + getKimiAcpModelOptions, + isKimiModelCatalogEmpty, + KIMI_AUTH_METHOD_ID, + KIMI_AUTH_REQUIRED_MESSAGE, + makeKimiAuthRequiredError, + resolveKimiAcpBaseModelId, + resolveKimiAcpModeId, + resolveKimiBinaryPath, +} from "./KimiAcpSupport.ts"; + +const configOptions = [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "kimi-for-coding", + options: [ + { value: "kimi-for-coding", name: "Kimi for Coding" }, + { value: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" }, + ], + }, + { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "plan", name: "Plan" }, + { value: "yolo", name: "YOLO" }, + ], + }, +] satisfies ReadonlyArray; + +describe("Kimi ACP model configuration", () => { + it("uses Kimi's terminal login auth method", () => { + expect(KIMI_AUTH_METHOD_ID).toBe("login"); + }); + + it("normalizes empty and custom Kimi model ids", () => { + expect(resolveKimiAcpBaseModelId(undefined)).toBe("kimi-for-coding"); + expect(resolveKimiAcpBaseModelId(" ")).toBe("kimi-for-coding"); + expect(resolveKimiAcpBaseModelId(" kimi-test-custom-model ")).toBe("kimi-test-custom-model"); + }); + + it("treats an empty model option list as the signed-out state", () => { + // A logged-out Kimi CLI still creates sessions but reports the model + // select with no options. The catalog check must distinguish that from + // a response with no model option at all (older CLI / unknown shape). + const emptyModelOption = { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "", + options: [], + } satisfies EffectAcpSchema.SessionConfigOption; + const modeOnlyOption = { + type: "select", + id: "mode", + name: "Mode", + category: "mode", + currentValue: "default", + options: [{ value: "default", name: "Default" }], + } satisfies EffectAcpSchema.SessionConfigOption; + + expect(isKimiModelCatalogEmpty([emptyModelOption, modeOnlyOption])).toBe(true); + expect(isKimiModelCatalogEmpty(configOptions)).toBe(false); + expect(isKimiModelCatalogEmpty([modeOnlyOption])).toBe(false); + expect(isKimiModelCatalogEmpty(undefined)).toBe(false); + expect(makeKimiAuthRequiredError().errorMessage).toBe(KIMI_AUTH_REQUIRED_MESSAGE); + }); + + it("reads the current and available models from the model config option", () => { + expect(currentKimiModelIdFromConfigOptions(configOptions)).toBe("kimi-for-coding"); + expect(getKimiAcpModelOptions(configOptions)).toEqual([ + { value: "kimi-for-coding", name: "Kimi for Coding" }, + { value: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" }, + ]); + }); + + it.effect("switches models through session/set_config_option", () => + Effect.gen(function* () { + const calls: Array<[string, string | boolean]> = []; + const selected = yield* applyKimiAcpModelSelection({ + runtime: { + setConfigOption: (configId, value) => + Effect.sync(() => { + calls.push([configId, value]); + return { configOptions }; + }), + }, + currentModelId: "kimi-for-coding", + requestedModelId: "kimi-for-coding-highspeed", + mapError: (cause) => cause.message, + }); + + expect(selected).toBe("kimi-for-coding-highspeed"); + expect(calls).toEqual([["model", "kimi-for-coding-highspeed"]]); + }), + ); + + it.effect("maps config write failures", () => + Effect.gen(function* () { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("unknown model"); + const error = yield* Effect.flip( + applyKimiAcpModelSelection({ + runtime: { setConfigOption: () => Effect.fail(failure) }, + currentModelId: "kimi-for-coding", + requestedModelId: "missing", + mapError: (cause) => cause.message, + }), + ); + expect(error).toBe(failure.message); + }), + ); +}); + +describe("Kimi ACP mode configuration", () => { + it("maps T3 runtime and interaction modes to Kimi modes", () => { + expect(resolveKimiAcpModeId({ runtimeMode: "full-access", interactionMode: undefined })).toBe( + "yolo", + ); + expect( + resolveKimiAcpModeId({ runtimeMode: "approval-required", interactionMode: undefined }), + ).toBe("default"); + expect( + resolveKimiAcpModeId({ runtimeMode: "auto-accept-edits", interactionMode: undefined }), + ).toBe("yolo"); + expect(resolveKimiAcpModeId({ runtimeMode: "full-access", interactionMode: "plan" })).toBe( + "plan", + ); + }); + + it.effect("writes the resolved mode through the mode config option", () => + Effect.gen(function* () { + const calls: Array<[string, string | boolean]> = []; + yield* applyKimiAcpModeSelection({ + runtime: { + setConfigOption: (configId, value) => + Effect.sync(() => { + calls.push([configId, value]); + return { configOptions }; + }), + }, + runtimeMode: "full-access", + interactionMode: "plan", + mapError: (cause) => cause.message, + }); + expect(calls).toEqual([["mode", "plan"]]); + }), + ); +}); + +it.layer(NodeServices.layer)("Kimi ACP spawn resolution", (it) => { + it.effect("uses the configured binary and expands KIMI_CODE_HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const spawn = yield* buildKimiAcpSpawnInput( + { binaryPath: "/opt/kimi", homePath: "~/.kimi-work" }, + "/tmp/project", + { PATH: "/bin", KEEP_ME: "yes" }, + ); + + expect(spawn).toEqual({ + command: "/opt/kimi", + args: ["acp"], + cwd: "/tmp/project", + env: { + PATH: "/bin", + KEEP_ME: "yes", + KIMI_CODE_HOME: path.resolve(NodeOS.homedir(), ".kimi-work"), + }, + }); + }), + ); + + it.effect("falls back to the well-known Windows install path when kimi is not on PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const expected = path.join(NodeOS.homedir(), ".kimi-code", "bin", "kimi.exe"); + const fakeFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + exists: (candidate) => Effect.succeed(candidate === expected), + }); + + const resolved = yield* resolveKimiBinaryPath( + { binaryPath: "kimi" }, + { PATH: "", PATHEXT: ".EXE" }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fakeFileSystem), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + expect(resolved).toBe(expected); + }), + ); +}); diff --git a/apps/server/src/provider/acp/KimiAcpSupport.ts b/apps/server/src/provider/acp/KimiAcpSupport.ts new file mode 100644 index 00000000000..661fa052640 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.ts @@ -0,0 +1,238 @@ +import * as NodeOS from "node:os"; + +import { + type KimiSettings, + type ProviderInteractionMode, + ProviderDriverKind, + type RuntimeMode, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +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 Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { expandHomePath } from "../../pathExpansion.ts"; +import { applyAcpModelSelectionIfChanged } from "./AcpModelSelection.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +export const KIMI_AUTH_METHOD_ID = "login"; +const KIMI_CODE_HOME_ENV = "KIMI_CODE_HOME"; +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); + +type KimiAcpRuntimeKimiSettings = Pick; + +interface KimiAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export const resolveKimiBinaryPath = Effect.fn("resolveKimiBinaryPath")(function* ( + kimiSettings: Pick | null | undefined, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const configured = kimiSettings?.binaryPath?.trim(); + if (configured && configured !== "kimi") { + return configured; + } + + const command = configured || "kimi"; + if (yield* isCommandAvailable(command, environment ? { env: environment } : {})) { + return command; + } + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const wellKnownPath = path.join( + NodeOS.homedir(), + ".kimi-code", + "bin", + platform === "win32" ? "kimi.exe" : "kimi", + ); + const exists = yield* fileSystem.exists(wellKnownPath).pipe(Effect.orElseSucceed(() => false)); + return exists ? wellKnownPath : command; +}); + +export const buildKimiAcpSpawnInput = Effect.fn("buildKimiAcpSpawnInput")(function* ( + kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const homePath = kimiSettings?.homePath?.trim(); + const path = yield* Path.Path; + const env = + homePath || environment + ? { + ...environment, + ...(homePath ? { [KIMI_CODE_HOME_ENV]: path.resolve(expandHomePath(homePath)) } : {}), + } + : undefined; + + return { + command: yield* resolveKimiBinaryPath(kimiSettings, environment), + args: ["acp"], + cwd, + ...(env ? { env } : {}), + }; +}); + +export const makeKimiAcpRuntime = ( + input: KimiAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | FileSystem.FileSystem | Path.Path | Scope.Scope +> => + Effect.gen(function* () { + const spawn = yield* buildKimiAcpSpawnInput(input.kimiSettings, input.cwd, input.environment); + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn, + authMethodId: KIMI_AUTH_METHOD_ID, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveKimiAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "kimi-for-coding"; + return normalizeModelSlug(base, KIMI_DRIVER_KIND) ?? "kimi-for-coding"; +} + +export function findKimiModelConfigOption( + configOptions: ReadonlyArray | null | undefined, +): Extract | undefined { + // Match by id only, mirroring the generic findSessionConfigOption used by + // the setConfigOption write path. Requiring the category tag as well would + // let a CLI-side metadata change break model discovery (empty catalog -> + // permanently "not authenticated") while writes kept working. + return configOptions?.find( + (option): option is Extract => + option.type === "select" && option.id.trim().toLowerCase() === "model", + ); +} + +export interface KimiAcpModelOption { + readonly value: string; + readonly name: string; +} + +export function getKimiAcpModelOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + const modelOption = findKimiModelConfigOption(configOptions); + if (!modelOption) { + return []; + } + return modelOption.options.flatMap((entry) => + "value" in entry + ? [{ value: entry.value.trim(), name: entry.name.trim() }] + : entry.options.map((option) => ({ + value: option.value.trim(), + name: option.name.trim(), + })), + ); +} + +export const KIMI_AUTH_REQUIRED_MESSAGE = + "Kimi Code is not authenticated. Run kimi login and try again."; + +// JSON-RPC error code the ACP "authentication required" signal uses. A +// signed-out Kimi CLI may reject the handshake with this code instead of +// returning an empty model catalog. +export const KIMI_AUTH_REQUIRED_ACP_CODE = -32000; + +/** + * True when an ACP error is the CLI's "authentication required" signal. Lets + * callers surface the "run kimi login" message when a signed-out session fails + * the handshake before its (empty) model catalog can even be read. + */ +export function isKimiAuthRequiredAcpError(error: EffectAcpErrors.AcpError): boolean { + return error._tag === "AcpRequestError" && error.code === KIMI_AUTH_REQUIRED_ACP_CODE; +} + +/** + * A logged-out Kimi CLI still creates sessions, but reports the "model" + * select with an empty option list. Detect that state so callers can fail + * with an authentication message instead of letting a model switch bounce + * off client-side config validation with an opaque "expected one of" error. + */ +export function isKimiModelCatalogEmpty( + configOptions: ReadonlyArray | null | undefined, +): boolean { + const modelOption = findKimiModelConfigOption(configOptions); + return modelOption !== undefined && getKimiAcpModelOptions(configOptions).length === 0; +} + +export function makeKimiAuthRequiredError(): EffectAcpErrors.AcpRequestError { + return new EffectAcpErrors.AcpRequestError({ + code: KIMI_AUTH_REQUIRED_ACP_CODE, + errorMessage: KIMI_AUTH_REQUIRED_MESSAGE, + data: { reason: "auth_required" }, + }); +} + +export function currentKimiModelIdFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): string | undefined { + return findKimiModelConfigOption(configOptions)?.currentValue?.trim() || undefined; +} + +export function applyKimiAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + return applyAcpModelSelectionIfChanged({ + currentModelId: input.currentModelId, + requestedModelId: input.requestedModelId, + setModel: (modelId) => input.runtime.setConfigOption("model", modelId), + mapError: input.mapError, + }); +} + +export function resolveKimiAcpModeId(input: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; +}): "default" | "plan" | "yolo" { + if (input.interactionMode === "plan") { + return "plan"; + } + // Only approval-required is supervised. Both full-access and + // auto-accept-edits are unrestricted implement modes (mirroring how the + // Cursor ACP path maps them), so both map to yolo — otherwise auto-accept + // users keep hitting approval prompts despite opting out of them. + return input.runtimeMode === "approval-required" ? "default" : "yolo"; +} + +export function applyKimiAcpModeSelection(input: { + readonly runtime: Pick; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + return input.runtime + .setConfigOption("mode", resolveKimiAcpModeId(input)) + .pipe(Effect.mapError(input.mapError), Effect.asVoid); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..622b48944bd 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { KimiDriver, type KimiDriverEnv } from "./Drivers/KimiDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | KimiDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray, +> { + readonly presentation: ServerProviderPresentation; + /** Always true here — the disabled case is handled before this scaffold runs. */ + readonly enabled: boolean; + readonly checkedAt: string; + readonly fallbackModels: ReadonlyArray; + readonly versionProbeTimeoutMs: number; + readonly discoveryTimeoutMs: number; + readonly runVersionCommand: Effect.Effect; + /** + * Discovery result passed opaquely to {@link buildDiscoveredSnapshot}. + * Defaults to a plain discovered-model list (Grok), but a provider can + * return a richer value — e.g. Kimi carries whether an empty catalog means + * "signed out" or "no model option at all" so the two are not conflated. + */ + readonly discoverModels: Effect.Effect; + readonly messages: CliProviderStatusProbeMessages; + readonly logMessages: CliProviderStatusProbeLogMessages; + /** + * Assemble the terminal snapshot from a successful discovery. Providers + * differ here: Grok maps an empty discovered-model list back to fallback + * models with unknown auth, while Kimi maps an empty catalog to an + * unauthenticated error only when the CLI actually reported an empty model + * option (signed out), and to a discovery error otherwise. + */ + readonly buildDiscoveredSnapshot: (input: { + readonly version: string | null; + readonly discoveredModels: TDiscovery; + }) => ServerProviderDraft; +} + +/** + * Run the shared CLI provider status-probe branch sequence. The caller owns + * the enabled check and `checkedAt`; this helper covers everything from the + * version probe through discovery. Every emitted snapshot, log, and message + * is supplied by the caller so output stays byte-identical per provider. + */ +export const runCliProviderStatusProbe = < + EVersion extends { readonly _tag: string }, + RVersion, + EDiscovery, + RDiscovery, + TDiscovery = ReadonlyArray, +>( + config: CliProviderStatusProbeConfig, +): Effect.Effect => + Effect.gen(function* () { + const { presentation, enabled, checkedAt, fallbackModels, messages, logMessages } = config; + + const versionResult = yield* config.runVersionCommand.pipe( + Effect.timeoutOption(config.versionProbeTimeoutMs), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning(logMessages.healthCheckFailed, { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation, + enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? messages.commandMissing + : messages.healthCheckFailed, + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation, + enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: messages.versionProbeTimeout, + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(versionOutput.stdout + "\n" + versionOutput.stderr); + if (versionOutput.code !== 0) { + yield* Effect.logWarning(logMessages.nonZeroExit, { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation, + enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: messages.nonZeroExit, + }, + }); + } + + const discoveryExit = yield* config.discoverModels.pipe( + Effect.timeoutOption(config.discoveryTimeoutMs), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning(logMessages.discoveryFailed, { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation, + enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: messages.discoveryFailed, + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning(logMessages.discoveryTimeout); + return buildServerProvider({ + presentation, + enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: messages.discoveryTimeout, + }, + }); + } + + return config.buildDiscoveredSnapshot({ + version, + discoveredModels: discoveryExit.value.value, + }); + }); + +/** + * Shared background version-advisory enrichment used by the simple CLI + * providers (Grok, Kimi). Republishes update/version advisory metadata and + * swallows failures as a warning. Providers whose enrichment must be skipped + * under some condition (e.g. Kimi when unauthenticated) pass `skip: true`. + */ +export const enrichCliProviderSnapshotAdvisory = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean | undefined; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; + readonly warningLogMessage: string; + readonly skip?: boolean; +}): Effect.Effect => { + if (input.skip) { + return Effect.void; + } + + return enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => input.publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning(input.warningLogMessage, { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index fd367acdf4c..3c3fe4af997 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,28 +1,8 @@ -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type * as EffectAcpErrors from "effect-acp/errors"; -import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; -import { TextGenerationError } from "@t3tools/contracts"; -import * as TextGeneration from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "./TextGenerationUtils.ts"; +import { makeStandardAcpTextGeneration } from "./StandardAcpTextGeneration.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -30,227 +10,33 @@ import { resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; -const GROK_TIMEOUT_MS = 180_000; - -const isTextGenerationError = Schema.is(TextGenerationError); - export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { - const crypto = yield* Crypto.Crypto; - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const runGrokJson = ({ - operation, - cwd, - prompt, - outputSchemaJson, - modelSelection, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - modelSelection: ModelSelection; - }): Effect.Effect => - Effect.gen(function* () { - const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); - const outputRef = yield* Ref.make(""); - const runtime = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner: commandSpawner, - cwd, - clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }).pipe(Effect.provideService(Crypto.Crypto, crypto)); - - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; - } - const content = update.content; - if (content.type !== "text") { - return Effect.void; - } - return Ref.update(outputRef, (current) => current + content.text); - }); - - const promptResult = yield* Effect.gen(function* () { - const started = yield* runtime.start(); - yield* applyGrokAcpModelSelection({ + return yield* makeStandardAcpTextGeneration( + { + displayName: "Grok", + agentName: "Grok Agent", + resolveBaseModelId: resolveGrokAcpBaseModelId, + makeRuntime: (input) => + makeGrokAcpRuntime({ + ...input, + grokSettings, + }), + applyModelSelection: ({ runtime, started, model, operation }) => + applyGrokAcpModelSelection({ runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), - requestedModelId: resolvedModel, + requestedModelId: model, mapError: (cause) => new TextGenerationError({ operation, detail: "Failed to set Grok ACP base model for text generation.", cause, }), - }); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(GROK_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ operation, detail: "Grok ACP request timed out." }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Grok ACP request failed.", - cause, - }), - ), - ); - - const trimmed = (yield* Ref.get(outputRef)).trim(); - if (!trimmed) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "Grok ACP request was cancelled." - : "Grok Agent returned empty output.", - }); - } - - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(trimmed)).pipe( - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Grok Agent returned invalid structured output.", - cause, - }), - ), - }), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Grok ACP text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runGrokJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("GrokTextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runGrokJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("GrokTextGeneration.generateBranchName")(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runGrokJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runGrokJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies TextGeneration.ThreadTitleGenerationResult; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; + }).pipe(Effect.asVoid), + }, + environment, + ); }); diff --git a/apps/server/src/textGeneration/KimiTextGeneration.ts b/apps/server/src/textGeneration/KimiTextGeneration.ts new file mode 100644 index 00000000000..9533fc1abcc --- /dev/null +++ b/apps/server/src/textGeneration/KimiTextGeneration.ts @@ -0,0 +1,64 @@ +import { type KimiSettings, TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + applyKimiAcpModelSelection, + currentKimiModelIdFromConfigOptions, + isKimiModelCatalogEmpty, + KIMI_AUTH_REQUIRED_MESSAGE, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, +} from "../provider/acp/KimiAcpSupport.ts"; +import { makeStandardAcpTextGeneration } from "./StandardAcpTextGeneration.ts"; + +export const makeKimiTextGeneration = Effect.fn("makeKimiTextGeneration")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + return yield* makeStandardAcpTextGeneration( + { + displayName: "Kimi Code", + agentName: "Kimi Code CLI", + resolveBaseModelId: resolveKimiAcpBaseModelId, + makeRuntime: (input) => + makeKimiAcpRuntime({ + ...input, + kimiSettings, + }), + applyModelSelection: ({ runtime, model, operation }) => + runtime.getConfigOptions.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to read Kimi ACP config options for text generation.", + cause, + }), + ), + Effect.flatMap((configOptions) => { + if (isKimiModelCatalogEmpty(configOptions)) { + return Effect.fail( + new TextGenerationError({ + operation, + detail: KIMI_AUTH_REQUIRED_MESSAGE, + }), + ); + } + return applyKimiAcpModelSelection({ + runtime, + currentModelId: currentKimiModelIdFromConfigOptions(configOptions), + requestedModelId: model, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kimi ACP base model for text generation.", + cause, + }), + }); + }), + Effect.asVoid, + ), + }, + environment, + ); +}); diff --git a/apps/server/src/textGeneration/StandardAcpTextGeneration.ts b/apps/server/src/textGeneration/StandardAcpTextGeneration.ts new file mode 100644 index 00000000000..9e94533b3d3 --- /dev/null +++ b/apps/server/src/textGeneration/StandardAcpTextGeneration.ts @@ -0,0 +1,305 @@ +/** + * StandardAcpTextGeneration — provider-agnostic ACP git-text generation core. + * + * Hosts `makeStandardAcpTextGeneration`, the shared implementation every + * ACP-backed text-generation driver (Grok, Kimi, …) delegates to. Providers + * supply spawn/model-selection specifics through + * {@link StandardAcpTextGenerationConfig}. + * + * @module StandardAcpTextGeneration + */ + +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import type * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; + +const ACP_TEXT_GENERATION_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export interface StandardAcpTextGenerationConfig { + readonly displayName: string; + readonly agentName: string; + readonly resolveBaseModelId: (model: string | null | undefined) => string; + readonly makeRuntime: ( + input: Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" + > & { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly environment?: NodeJS.ProcessEnv; + }, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | FileSystem.FileSystem | Path.Path | Scope.Scope + >; + readonly applyModelSelection: (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + /** + * The runtime start result. Drivers that read the current base model from + * session setup (like Grok, via `started.sessionSetupResult`) use this; + * drivers that read state directly from the runtime (like Kimi, via + * `runtime.getConfigOptions`) may ignore it. + */ + readonly started: AcpSessionRuntime.AcpSessionRuntimeStartResult; + readonly model: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => Effect.Effect; +} + +export const makeStandardAcpTextGeneration = Effect.fn("makeStandardAcpTextGeneration")(function* ( + config: StandardAcpTextGenerationConfig, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + + const runAcpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = config.resolveBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* config + .makeRuntime({ + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }) + .pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(Crypto.Crypto, crypto), + ); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* config.applyModelSelection({ + runtime, + started, + model: resolvedModel, + operation, + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(ACP_TEXT_GENERATION_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: config.displayName + " ACP request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: config.displayName + " ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? config.displayName + " ACP request was cancelled." + : config.agentName + " returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: config.agentName + " returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: config.displayName + " ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("StandardAcpTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runAcpJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("StandardAcpTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runAcpJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("StandardAcpTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("StandardAcpTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..7e61481c3f6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,7 +7,13 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "kimi" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..b73afad7bd7 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,18 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const KimiIcon: Icon = ({ className, ...props }) => ( + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..9f7cc35bd2f 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, KimiIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("kimi")]: KimiIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index f1100ab93d2..24892c8b2ee 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -34,6 +34,8 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("kimi"), + label: "Kimi Code", + icon: KimiIcon, + badgeLabel: "Early Access", + settingsSchema: KimiSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..828e304fddc 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -267,7 +267,7 @@ export interface ComposerThreadDraftState { * branded slug) so a default `codex` instance and a user-authored * `codex_personal` instance each persist their own selected model. Every * historical `ProviderDriverKind` literal (`codex` / `claudeAgent` / `cursor` / - * `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so + * `grok` / `kimi` / `opencode`) also satisfies the `ProviderInstanceId` slug pattern, so * legacy kind-keyed drafts round-trip unchanged. */ modelSelectionByProvider: Partial>; @@ -761,7 +761,14 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "kimi", + "opencode", + ] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -920,7 +927,14 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "kimi", + "opencode", + ] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2654,7 +2668,14 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "kimi", + "opencode", + ] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f..89eb231b405 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -36,6 +36,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Codex"; case "cursor": return "Cursor"; + case "kimi": + return "Kimi Code"; case "opencode": return "OpenCode"; default: { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 3d973ccca74..1d7a7ccc588 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -122,6 +122,27 @@ describe("instance-scoped model selection", () => { ); }); + it("includes Kimi custom models from the selected provider instance", () => { + const providers = [provider({ provider: ProviderDriverKind.make("kimi"), instanceId: "kimi" })]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("kimi")]: { + driver: ProviderDriverKind.make("kimi"), + config: { customModels: ["kimi-test-custom-model"] }, + }, + }, + }; + const kimi = deriveProviderInstanceEntries(providers).find( + (entry) => entry.instanceId === "kimi", + )!; + + expect(getAppModelOptionsForInstance(settings, kimi).map((option) => option.slug)).toContain( + "kimi-test-custom-model", + ); + }); + it("does not inject an unknown selected slug into the stock instance list", () => { const providers = [ provider({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..bd8619475b1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("kimi"), + label: "Kimi Code", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..bb94b4c41ef 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -141,6 +142,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [KIMI_DRIVER_KIND]: "Kimi Code", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..908a2bfe7ba 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -42,6 +42,12 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.kimi).toEqual({ + enabled: true, + binaryPath: "kimi", + homePath: "", + customModels: [], + }); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b05f397bf5c..45dca575fa5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -305,6 +305,39 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const KimiSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kimi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kimi Code CLI binary.", + providerSettingsForm: { placeholder: "kimi", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Kimi HOME path", + description: + "Custom KIMI_CODE_HOME used for this instance. Keeps ~/.kimi-code state separate.", + providerSettingsForm: { placeholder: "~/.kimi-code", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "homePath"], + }, +); +export type KimiSettings = typeof KimiSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -398,6 +431,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kimi: KimiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -493,6 +527,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KimiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -522,6 +563,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + kimi: Schema.optionalKey(KimiSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),