From 3615f95d92304865e4fd0d2207239f155d9298bd Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Wed, 19 Aug 2026 17:49:15 +0500 Subject: [PATCH 01/22] feat(providers): add Kimi provider with in-app OAuth sign-in Wraps the Kimi CLI over ACP mirroring the Grok driver stack, with a server-side "Sign in with Kimi" device flow that writes the CLI's own credential file. Model ids are translated to the kimi-code/ config.toml alias namespace that session/set_model requires. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mobile/src/components/ProviderIcon.tsx | 15 +- apps/server/src/auth/RpcAuthorization.ts | 2 + .../server/src/provider/Drivers/KimiDriver.ts | 166 ++ .../server/src/provider/Layers/KimiAdapter.ts | 1379 +++++++++++++++++ .../src/provider/Layers/KimiProvider.test.ts | 150 ++ .../src/provider/Layers/KimiProvider.ts | 381 +++++ .../src/provider/Services/KimiAdapter.ts | 16 + .../src/provider/acp/KimiAcpSupport.test.ts | 214 +++ .../server/src/provider/acp/KimiAcpSupport.ts | 181 +++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/kimi/KimiOAuth.test.ts | 274 ++++ apps/server/src/provider/kimi/KimiOAuth.ts | 294 ++++ .../src/textGeneration/KimiTextGeneration.ts | 262 ++++ apps/server/src/ws.ts | 43 +- apps/web/src/components/Icons.tsx | 15 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/KimiSignInControl.tsx | 86 + .../settings/ProviderInstanceCard.tsx | 7 + .../settings/ProviderSettingsPanel.tsx | 9 + .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + docs/internals/providers.md | 10 +- docs/user/providers-kimi.md | 58 + packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/server.ts | 91 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/kimiAuth.ts | 79 + packages/contracts/src/model.ts | 5 + packages/contracts/src/rpc.ts | 18 + packages/contracts/src/settings.ts | 42 + pnpm-lock.yaml | 2 + 31 files changed, 3826 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/provider/Drivers/KimiDriver.ts create mode 100644 apps/server/src/provider/Layers/KimiAdapter.ts create mode 100644 apps/server/src/provider/Layers/KimiProvider.test.ts create mode 100644 apps/server/src/provider/Layers/KimiProvider.ts create mode 100644 apps/server/src/provider/Services/KimiAdapter.ts create mode 100644 apps/server/src/provider/acp/KimiAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/KimiAcpSupport.ts create mode 100644 apps/server/src/provider/kimi/KimiOAuth.test.ts create mode 100644 apps/server/src/provider/kimi/KimiOAuth.ts create mode 100644 apps/server/src/textGeneration/KimiTextGeneration.ts create mode 100644 apps/web/src/components/settings/KimiSignInControl.tsx create mode 100644 docs/user/providers-kimi.md create mode 100644 packages/contracts/src/kimiAuth.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..c327a0929d6d 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,4 +1,4 @@ -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { @@ -39,6 +39,19 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "kimi") { + const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + {/* Stylized "K" mark matching the web KimiIcon: upright stem plus two + angled strokes, with the upper arm ending in Kimi's dot accent. */} + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..8819ccaf0edf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + // Signing in writes provider credentials on the host, like other provider mutations. + [WS_METHODS.kimiAuthSignIn]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/KimiDriver.ts b/apps/server/src/provider/Drivers/KimiDriver.ts new file mode 100644 index 000000000000..e9e82d641d98 --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiDriver.ts @@ -0,0 +1,166 @@ +import { KimiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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 * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeKimiTextGeneration } from "../../textGeneration/KimiTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.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 type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +const DRIVER_KIND = ProviderDriverKind.make("kimi"); +// The Kimi CLI installs via Moonshot's install script (or a global npm +// package the script manages itself), so no T3-managed update path applies; +// updates stay manual. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type KimiDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | 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: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const KimiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kimi", + supportsMultipleInstances: true, + }, + configSchema: KimiSettings, + defaultConfig: (): KimiSettings => decodeKimiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies KimiSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeKimiAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeKimiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkKimiProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKimiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichKimiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Kimi snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/KimiAdapter.ts b/apps/server/src/provider/Layers/KimiAdapter.ts new file mode 100644 index 000000000000..c77b63dc9787 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -0,0 +1,1379 @@ +import { + ApprovalRequestId, + type KimiSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + advertisedKimiModelIdsFromSessionSetup, + applyKimiAcpModelSelection, + currentKimiModelIdFromSessionSetup, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, +} from "../acp/KimiAcpSupport.ts"; +import { type KimiAdapterShape } from "../Services/KimiAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("kimi"); +const KIMI_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface KimiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface KimiSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** 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; + currentModelId: string | undefined; + /** Model ids the agent advertised at session setup; kimi-code advertises none. */ + readonly advertisedModelIds: ReadonlyArray | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: KimiSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: KimiSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: KimiSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +function parseKimiResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== KIMI_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + return response?.stopReason ?? null; +} + +export function kimiPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("kimi"); + 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 Kimi runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + 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 settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = kimiPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Kimi notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: KimiSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + 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: KimiSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + 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: KimiAdapterShape["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 kimiModelSelection = + 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 sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseKimiResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeKimiAcpRuntime({ + kimiSettings, + ...(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.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + 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(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + 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, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Kimi ACP callback.", + cause, + }), + ), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModelId = kimiModelSelection?.model + ? resolveKimiAcpBaseModelId(kimiModelSelection.model) + : undefined; + const advertisedModelIds = advertisedKimiModelIdsFromSessionSetup( + started.sessionSetupResult, + ); + const boundModelId = yield* applyKimiAcpModelSelection({ + runtime: acp, + currentModelId: currentKimiModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + advertisedModelIds, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveKimiAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: KIMI_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: KimiSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModelId, + advertisedModelIds, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Kimi runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + 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: "Kimi 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: KimiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + 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; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveKimiAcpBaseModelId(turnModelSelection.model) + : undefined; + const currentModelId = yield* applyKimiAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + requestedModelId: requestedTurnModelId, + advertisedModelIds: ctx.advertisedModelIds, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModelId; + const displayModel = currentModelId + ? resolveKimiAcpBaseModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kimi prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Kimi prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kimi session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kimi session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kimi session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Kimi prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: KimiAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: KimiAdapterShape["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: KimiAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Kimi sessions do not issue user-input requests (request: ${requestId}).`, + }); + }); + + const readThread: KimiAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: KimiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Kimi ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: KimiAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: KimiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: KimiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: KimiAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + 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 KimiAdapterShape; + }); +} 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 000000000000..fbb74a9566ca --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.test.ts @@ -0,0 +1,150 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +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 { KimiSettings } from "@t3tools/contracts"; + +import { + buildInitialKimiProviderSnapshot, + buildKimiDiscoveredModelsFromSessionModelState, + checkKimiProviderStatus, +} from "./KimiProvider.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +describe("buildInitialKimiProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot( + decodeKimiSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot(decodeKimiSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Kimi"); + expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + }), + ); +}); + +describe("buildKimiDiscoveredModelsFromSessionModelState", () => { + it("returns nothing for an absent or empty model state", () => { + expect(buildKimiDiscoveredModelsFromSessionModelState(undefined)).toEqual([]); + expect( + buildKimiDiscoveredModelsFromSessionModelState({ + currentModelId: "k3", + availableModels: [], + }), + ).toEqual([]); + }); + + it("collapses thinking variants onto their base model and marks the current default", () => { + const models = buildKimiDiscoveredModelsFromSessionModelState({ + currentModelId: "k3,thinking", + availableModels: [ + { modelId: "k3", name: "K3" }, + { modelId: "k3,thinking", name: "K3 (Thinking)" }, + { modelId: "kimi-for-coding", name: "Kimi K2.7 Code" }, + ], + }); + + expect(models.map((model) => model.slug)).toEqual(["k3", "kimi-for-coding"]); + expect(models[0]?.isDefault).toBe(true); + expect(models[1]?.isDefault).toBeUndefined(); + }); +}); + +it.layer(NodeServices.layer)("checkKimiProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkKimiProviderStatus( + decodeKimiSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/kimi-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken kimi install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-version-" }); + const kimiPath = path.join(dir, "kimi"); + yield* fs.writeFileString( + kimiPath, + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), + ); + yield* fs.chmod(kimiPath, 0o755); + + return yield* checkKimiProviderStatus( + decodeKimiSettings({ enabled: true, binaryPath: kimiPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Kimi CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("reports an error when ACP model discovery is unavailable", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-success-" }); + const kimiPath = path.join(dir, "kimi"); + yield* fs.writeFileString( + kimiPath, + ["#!/bin/sh", 'printf "kimi-cli 1.49.0\\n"', "exit 0", ""].join("\n"), + ); + yield* fs.chmod(kimiPath, 0o755); + + return yield* checkKimiProviderStatus( + decodeKimiSettings({ enabled: true, binaryPath: kimiPath }), + ); + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.installed).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + expect(snapshot.message).toContain("ACP startup failed"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiProvider.ts b/apps/server/src/provider/Layers/KimiProvider.ts new file mode 100644 index 000000000000..c52a9815ae25 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.ts @@ -0,0 +1,381 @@ +import { + type KimiSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; +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"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + isKimiAuthRequiredError, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, + resolveKimiHomePath, +} from "../acp/KimiAcpSupport.ts"; + +const KIMI_PRESENTATION = { + displayName: "Kimi", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +export const KIMI_NOT_SIGNED_IN_MESSAGE = + "Kimi CLI is installed but not signed in. Use Sign in with Kimi in Settings or run `kimi login`."; + +// Static fallback matching current kimi-cli builds. Live ACP discovery +// replaces this list whenever the CLI is installed and signed in. +const KIMI_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "k3", + name: "Kimi K3", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "kimi-for-coding", + name: "Kimi K2.7 Coding", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "kimi-for-coding-highspeed", + name: "Kimi K2.7 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 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 CLI availability...", + }, + }); + }); +} + +function kimiModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = KIMI_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildKimiDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const currentBaseModelId = modelState.currentModelId + ? resolveKimiAcpBaseModelId(modelState.currentModelId) + : undefined; + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveKimiAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(slug === currentBaseModelId ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +const discoverKimiModelsViaAcp = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + 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" }, + }); + const started = yield* acp.start(); + return buildKimiDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); + }).pipe(Effect.scoped); + +const runKimiVersionCommand = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = kimiSettings.binaryPath || "kimi"; + const homePath = resolveKimiHomePath(kimiSettings); + const env = homePath ? { ...environment, KIMI_CODE_HOME: homePath } : environment; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env, + 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 +> { + 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 is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runKimiVersionCommand(kimiSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Kimi CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Kimi CLI (`kimi`) is not installed or not on PATH. Install it from kimi.com/code or with `npm install -g @moonshot-ai/kimi-code`." + : "Failed to execute Kimi CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but timed out while running `kimi --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Kimi CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but failed to run.", + }, + }); + } + + const discoveryExit = yield* discoverKimiModelsViaAcp(kimiSettings, environment).pipe( + Effect.timeoutOption(KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + const failure = Cause.findErrorOption(discoveryExit.cause); + if (Option.isSome(failure) && isKimiAuthRequiredError(failure.value)) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unauthenticated" }, + message: KIMI_NOT_SIGNED_IN_MESSAGE, + }, + }); + } + yield* Effect.logWarning("Kimi ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Kimi ACP model discovery timed out after ${KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Kimi CLI is installed but ACP startup timed out after ${KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discoveredModels = discoveryExit.value.value; + const models = + discoveredModels.length > 0 + ? kimiModelsFromSettings(kimiSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: kimiSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const enrichKimiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Kimi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Services/KimiAdapter.ts b/apps/server/src/provider/Services/KimiAdapter.ts new file mode 100644 index 000000000000..c8e15aba78d4 --- /dev/null +++ b/apps/server/src/provider/Services/KimiAdapter.ts @@ -0,0 +1,16 @@ +/** + * KimiAdapter — shape type for the Kimi 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 adapter contract. + */ +export interface KimiAdapterShape extends ProviderAdapterShape {} 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 000000000000..56c22ea7307a --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { + applyKimiAcpModelSelection, + buildKimiAcpSpawnInput, + isKimiAuthRequiredError, + resolveKimiAcpBaseModelId, + resolveKimiAcpWireModelId, +} from "./KimiAcpSupport.ts"; + +describe("resolveKimiAcpBaseModelId", () => { + it("normalizes empty and custom Kimi model ids", () => { + expect(resolveKimiAcpBaseModelId(undefined)).toBe("k3"); + expect(resolveKimiAcpBaseModelId(" ")).toBe("k3"); + expect(resolveKimiAcpBaseModelId(" kimi-for-coding ")).toBe("kimi-for-coding"); + }); + + it("strips the ,thinking variant suffix to the base model id", () => { + expect(resolveKimiAcpBaseModelId("k3,thinking")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("kimi-for-coding , thinking")).toBe("kimi-for-coding"); + }); + + it("strips the kimi-code/ namespace prefix used by config.toml aliases", () => { + expect(resolveKimiAcpBaseModelId("kimi-code/k3")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("kimi-code/k3,thinking")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("moonshot-ai/kimi-k3")).toBe("moonshot-ai/kimi-k3"); + }); +}); + +describe("resolveKimiAcpWireModelId", () => { + it("namespaces managed ids when nothing was advertised (Kimi Code CLI)", () => { + expect(resolveKimiAcpWireModelId("k3")).toBe("kimi-code/k3"); + expect(resolveKimiAcpWireModelId("kimi-for-coding")).toBe("kimi-code/kimi-for-coding"); + expect(resolveKimiAcpWireModelId("moonshot-ai/kimi-k3")).toBe("moonshot-ai/kimi-k3"); + expect(resolveKimiAcpWireModelId("k3", [])).toBe("kimi-code/k3"); + }); + + it("prefers a matching advertised id (kimi-cli advertises bare ids)", () => { + expect(resolveKimiAcpWireModelId("k3", ["k3", "k3,thinking", "kimi-for-coding"])).toBe("k3"); + expect(resolveKimiAcpWireModelId("k3", ["k3,thinking", "k3"])).toBe("k3"); + expect(resolveKimiAcpWireModelId("k3", ["k3,thinking"])).toBe("k3,thinking"); + expect(resolveKimiAcpWireModelId("k3", ["kimi-code/k3", "kimi-code/kimi-for-coding"])).toBe( + "kimi-code/k3", + ); + }); + + it("passes an unadvertised custom id through untouched", () => { + expect(resolveKimiAcpWireModelId("my-custom-model", ["k3", "kimi-for-coding"])).toBe( + "my-custom-model", + ); + }); +}); + +describe("buildKimiAcpSpawnInput", () => { + it("spawns `kimi acp` with the configured binary", () => { + const spawn = buildKimiAcpSpawnInput( + { binaryPath: "/usr/local/bin/kimi", homePath: "" }, + "/tmp/project", + ); + + expect(spawn).toEqual({ + command: "/usr/local/bin/kimi", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("injects KIMI_CODE_HOME when a homePath is configured", () => { + const spawn = buildKimiAcpSpawnInput( + { binaryPath: "kimi", homePath: "/data/kimi-work" }, + "/tmp/project", + { PATH: "/bin" }, + ); + + expect(spawn).toEqual({ + command: "kimi", + args: ["acp"], + cwd: "/tmp/project", + env: { + PATH: "/bin", + KIMI_CODE_HOME: "/data/kimi-work", + }, + }); + }); +}); + +describe("isKimiAuthRequiredError", () => { + it("matches the RFC auth-required error code", () => { + expect(isKimiAuthRequiredError(EffectAcpErrors.AcpRequestError.authRequired())).toBe(true); + }); + + it("matches failures from the authenticate request", () => { + expect( + isKimiAuthRequiredError( + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "token missing", + method: "authenticate", + }), + ), + ).toBe(true); + }); + + it("rejects unrelated errors", () => { + expect( + isKimiAuthRequiredError(EffectAcpErrors.AcpRequestError.invalidParams("bad params")), + ).toBe(false); + expect(isKimiAuthRequiredError(new Error("nope"))).toBe(false); + }); +}); + +describe("applyKimiAcpModelSelection", () => { + const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { + const modelCalls: Array = []; + const runtime = { + setSessionModel: (modelId: string) => + Effect.gen(function* () { + modelCalls.push(modelId); + if (failure) return yield* failure; + return {}; + }), + }; + return { runtime, modelCalls }; + }; + + it.effect("calls session/set_model when the requested model differs from current", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "kimi-for-coding", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual(["kimi-code/kimi-for-coding"]); + expect(result).toBe("kimi-for-coding"); + }), + ); + + it.effect("uses the advertised wire id when the session advertised models", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "kimi-for-coding", + advertisedModelIds: ["k3", "k3,thinking", "kimi-for-coding"], + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual(["kimi-for-coding"]); + expect(result).toBe("kimi-for-coding"); + }), + ); + + it.effect("skips set_model when requested matches current", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "k3", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("k3"); + }), + ); + + it.effect("treats a thinking-variant current model as its base id", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3,thinking", + requestedModelId: "k3", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("k3,thinking"); + }), + ); + + it.effect("skips set_model when no model is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("k3"); + }), + ); + + it.effect("propagates session/set_model failures via mapError", () => + Effect.gen(function* () { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("session id not known"); + const { runtime } = makeRecordingRuntime(failure); + const error = yield* Effect.flip( + applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "kimi-for-coding", + mapError: (cause) => cause.message, + }), + ); + expect(error).toBe(failure.message); + }), + ); +}); diff --git a/apps/server/src/provider/acp/KimiAcpSupport.ts b/apps/server/src/provider/acp/KimiAcpSupport.ts new file mode 100644 index 000000000000..aa015da43da4 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.ts @@ -0,0 +1,181 @@ +import { type KimiSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import { expandHomePath } from "../../pathExpansion.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const KIMI_CODE_HOME_ENV = "KIMI_CODE_HOME"; +// kimi-cli advertises a single terminal-auth method: `login`. The ACP +// `authenticate` call only validates the stored OAuth token; the actual +// device flow happens through `kimi login` (or T3's in-app sign-in, which +// writes the same credentials file). +const KIMI_AUTH_METHOD_LOGIN = "login"; +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 function resolveKimiHomePath( + kimiSettings: Pick | null | undefined, +): string | undefined { + const homePath = kimiSettings?.homePath?.trim(); + return homePath ? expandHomePath(homePath) : undefined; +} + +export function buildKimiAcpSpawnInput( + kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const homePath = resolveKimiHomePath(kimiSettings); + return { + command: kimiSettings?.binaryPath || "kimi", + args: ["acp"], + cwd, + ...(environment || homePath + ? { + env: { + ...environment, + ...(homePath ? { [KIMI_CODE_HOME_ENV]: homePath } : {}), + }, + } + : {}), + }; +} + +export const makeKimiAcpRuntime = ( + input: KimiAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildKimiAcpSpawnInput(input.kimiSettings, input.cwd, input.environment), + authMethodId: KIMI_AUTH_METHOD_LOGIN, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +// kimi-code aliases its managed models as `kimi-code/` in config.toml, +// and `session/set_model` only accepts those alias ids. +const KIMI_CODE_MODEL_NAMESPACE = "kimi-code/"; + +/** + * Kimi model ids may carry a `kimi-code/` namespace prefix and a `,thinking` + * variant suffix (e.g. `kimi-code/k3,thinking`). Selection and display always + * use the base id; the thinking level is a separate session config option in + * current kimi-cli builds. + */ +export function resolveKimiAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const withoutVariant = trimmed?.split(",", 1)[0]?.trim(); + const withoutNamespace = withoutVariant?.startsWith(KIMI_CODE_MODEL_NAMESPACE) + ? withoutVariant.slice(KIMI_CODE_MODEL_NAMESPACE.length) + : withoutVariant; + const base = withoutNamespace && withoutNamespace.length > 0 ? withoutNamespace : "k3"; + return normalizeModelSlug(base, KIMI_DRIVER_KIND) ?? "k3"; +} + +/** + * The ACP wire id for a base model id, resolved against the ids the agent + * advertised at session setup when it did. + * + * kimi-cli (PyPI) advertises `availableModels` with bare ids (`k3`, + * `k3,thinking`) and expects those on `session/set_model`. Kimi Code CLI + * (~0.37) advertises no models at all and only accepts its config.toml alias + * ids, which namespace managed models as `kimi-code/`. Matching an + * advertised id wins; with nothing advertised, bare ids get the `kimi-code/` + * namespace; ids that already carry a namespace (custom models such as + * `moonshot-ai/kimi-k3`) always pass through as-is. + */ +export function resolveKimiAcpWireModelId( + baseModelId: string, + advertisedModelIds?: ReadonlyArray | undefined, +): string { + if (advertisedModelIds && advertisedModelIds.length > 0) { + const matches = advertisedModelIds.filter( + (advertised) => resolveKimiAcpBaseModelId(advertised) === baseModelId, + ); + // Prefer the plain id over its `,thinking` variant when both are advertised. + const match = matches.find((advertised) => !advertised.includes(",")) ?? matches[0]; + if (match !== undefined) { + return match; + } + // A custom model the agent did not advertise: trust the configured id. + return baseModelId; + } + return baseModelId.includes("/") ? baseModelId : `${KIMI_CODE_MODEL_NAMESPACE}${baseModelId}`; +} + +export function advertisedKimiModelIdsFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray | undefined { + const models = sessionSetupResult.models?.availableModels; + return models && models.length > 0 ? models.map((model) => model.modelId) : undefined; +} + +export function currentKimiModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyKimiAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly advertisedModelIds?: ReadonlyArray | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const currentBaseModelId = input.currentModelId + ? resolveKimiAcpBaseModelId(input.currentModelId) + : undefined; + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== currentBaseModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setSessionModel(resolveKimiAcpWireModelId(input.requestedModelId, input.advertisedModelIds)) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +/** True when an ACP failure means "signed out", i.e. `authenticate` was rejected. */ +export function isKimiAuthRequiredError(error: unknown): boolean { + return isAcpRequestError(error) && (error.code === -32000 || error.method === "authenticate"); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..622b48944bd8 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 { + it("matches kimi-cli's credential file shape", () => { + const json = buildKimiCredentialsJson( + { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + scope: "kimi-code", + token_type: "Bearer", + }, + 1_000_000, + ); + expect(JSON.parse(json)).toEqual({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_at: 1_900, + scope: "kimi-code", + token_type: "Bearer", + expires_in: 900, + }); + expect(json.endsWith("\n")).toBe(true); + }); + + it("fills kimi-cli defaults for optional token fields", () => { + const parsed = JSON.parse( + buildKimiCredentialsJson({ access_token: "access-1", refresh_token: "refresh-1" }, 0), + ); + expect(parsed.expires_in).toBe(900); + expect(parsed.scope).toBe("kimi-code"); + expect(parsed.token_type).toBe("Bearer"); + }); +}); + +describe("resolveKimiCodeHome", () => { + it("prefers a configured home path over the CLI default", () => { + expect(resolveKimiCodeHome("/data/kimi-work")).toBe("/data/kimi-work"); + expect(resolveKimiCodeHome(" ")).toContain(".kimi-code"); + expect(resolveKimiCodeHome(undefined)).toContain(".kimi-code"); + }); +}); + +describe("resolveKimiSignInHomePath", () => { + it("returns undefined without settings", () => { + expect(resolveKimiSignInHomePath(undefined, undefined)).toBeUndefined(); + }); + + it("prefers the targeted instance's homePath", () => { + const settings = decodeServerSettings({ + providers: { kimi: { homePath: "/legacy/home" } }, + providerInstances: { + kimi_work: { driver: "kimi", config: { homePath: "/work/home" } }, + }, + }); + expect(resolveKimiSignInHomePath(settings, ProviderInstanceId.make("kimi_work"))).toBe( + "/work/home", + ); + }); + + it("falls back to the legacy providers.kimi blob", () => { + const settings = decodeServerSettings({ + providers: { kimi: { homePath: "/legacy/home" } }, + }); + expect(resolveKimiSignInHomePath(settings, undefined)).toBe("/legacy/home"); + expect(resolveKimiSignInHomePath(settings, ProviderInstanceId.make("kimi"))).toBe( + "/legacy/home", + ); + }); + + it("returns undefined when no home path is configured anywhere", () => { + expect(resolveKimiSignInHomePath(decodeServerSettings({}), undefined)).toBeUndefined(); + }); +}); + +interface RecordedRequest { + readonly url: string; + readonly params: URLSearchParams; +} + +type MockResponse = { readonly status: number; readonly body: unknown }; + +const makeKimiOAuthHttpLayer = ( + requests: Array, + respond: (url: string, callIndex: number) => MockResponse, +) => { + let calls = 0; + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, params: new URLSearchParams(body) }); + const response = respond(request.url, calls); + calls += 1; + return HttpClientResponse.fromWeb( + request, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mock wire payloads are free-form test fixtures. + new Response(JSON.stringify(response.body), { + status: response.status, + headers: { "content-type": "application/json" }, + }), + ); + }), + ), + ); +}; + +const DEVICE_AUTHORIZATION_BODY = { + device_code: "device-code-1", + user_code: "ABCD-1234", + verification_uri: "https://auth.kimi.com/device", + verification_uri_complete: "https://auth.kimi.com/device?code=ABCD-1234", + expires_in: 600, + interval: 5, +}; + +it.layer(NodeServices.layer)("signInWithKimi", (it) => { + it.effect("emits verification, then writes the credential and completes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-oauth-" }); + + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url, callIndex) => { + if (url.includes("device_authorization")) { + return { status: 200, body: DEVICE_AUTHORIZATION_BODY }; + } + return callIndex < 2 + ? { status: 400, body: { error: "authorization_pending" } } + : { + status: 200, + body: { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + scope: "kimi-code", + token_type: "Bearer", + }, + }; + }); + + const collected = yield* signInWithKimi({ homePath: home }).pipe( + Stream.runCollect, + Effect.provide(httpLayer), + Effect.forkChild, + ); + // First poll waits the advertised interval; the second follows a + // pending answer. Two adjustments release both sleeps. + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("5 seconds"); + const events = yield* Fiber.join(collected); + + expect(events).toEqual([ + { + type: "verification", + verificationUri: "https://auth.kimi.com/device?code=ABCD-1234", + userCode: "ABCD-1234", + expiresInSeconds: 600, + }, + { type: "completed" }, + ]); + + const grants = requests.filter((request) => request.url.includes("/api/oauth/token")); + expect(grants).toHaveLength(2); + expect(grants[0]?.params.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:device_code", + ); + expect(grants[0]?.params.get("device_code")).toBe("device-code-1"); + + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting on the raw credential file kimi-cli reads. + const credentials = JSON.parse( + yield* fs.readFileString(path.join(home, "credentials", "kimi-code.json")), + ); + expect(credentials.access_token).toBe("access-1"); + expect(credentials.refresh_token).toBe("refresh-1"); + expect(credentials.token_type).toBe("Bearer"); + }), + ); + + it.effect("fails with `denied` when the user rejects the sign-in", () => + Effect.gen(function* () { + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { status: 400, body: { error: "access_denied" } }, + ); + + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthError); + expect(error.reason).toBe("denied"); + }), + ); + + it.effect("fails with `expired` when the device authorization lapses", () => + Effect.gen(function* () { + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { status: 400, body: { error: "expired_token" } }, + ); + + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error.reason).toBe("expired"); + }), + ); +}); + +it.layer(NodeServices.layer)("writeKimiCredentials", (it) => { + it.effect("writes atomically into the credentials directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-creds-" }); + + const credentialsPath = yield* writeKimiCredentials(home, { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + }); + + expect(credentialsPath).toBe(path.join(home, "credentials", "kimi-code.json")); + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting on the raw credential file kimi-cli reads. + const parsed = JSON.parse(yield* fs.readFileString(credentialsPath)); + expect(parsed.access_token).toBe("access-1"); + + // No stray temp files left behind. + const entries = yield* fs.readDirectory(path.join(home, "credentials")); + expect(entries).toEqual(["kimi-code.json"]); + }), + ); +}); diff --git a/apps/server/src/provider/kimi/KimiOAuth.ts b/apps/server/src/provider/kimi/KimiOAuth.ts new file mode 100644 index 000000000000..5b3bd79f5448 --- /dev/null +++ b/apps/server/src/provider/kimi/KimiOAuth.ts @@ -0,0 +1,294 @@ +/** + * KimiOAuth — server-side "Sign in with Kimi" via the OAuth 2.0 Device + * Authorization Grant (RFC 8628) against Moonshot's auth service. + * + * The flow produces exactly the credential file the Kimi CLI writes for + * itself (`$KIMI_CODE_HOME/credentials/kimi-code.json`), so after a + * successful sign-in `kimi` — and therefore the Kimi provider — is + * authenticated without ever opening a terminal. Token refresh stays with + * the CLI, which already refreshes on use with cross-process locking. + * + * Endpoints, client id, and the credential format mirror kimi-cli + * (`src/kimi_cli/auth/oauth.py`). + * + * @module provider/kimi/KimiOAuth + */ +import * as NodeOS from "node:os"; + +import { + KimiAuthError, + type KimiAuthSignInEvent, + KimiSettings, + type ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const DEVICE_AUTHORIZATION_PATH = "/api/oauth/device_authorization"; +const TOKEN_PATH = "/api/oauth/token"; +// Public device-flow client id shipped inside kimi-cli; not a secret. +const KIMI_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const KIMI_CODE_HOME_DIR_NAME = ".kimi-code"; +const CREDENTIALS_DIR_NAME = "credentials"; +const CREDENTIALS_FILE_NAME = "kimi-code.json"; +// RFC 8628 defaults: poll every 5s unless told otherwise, and never poll +// past the device authorization's own expiry (capped defensively). +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_EXPIRES_IN_SECONDS = 600; +const MAX_SIGN_IN_DURATION = Duration.minutes(15); + +/** Where the Kimi CLI resolves its data root, honoring a per-instance homePath. */ +export function resolveKimiCodeHome(homePath: string | null | undefined): string { + const trimmed = homePath?.trim(); + return trimmed ? expandHomePath(trimmed) : `${NodeOS.homedir()}/${KIMI_CODE_HOME_DIR_NAME}`; +} + +const decodeKimiSettingsExit = Schema.decodeUnknownExit(KimiSettings); + +/** + * KIMI_CODE_HOME override for a sign-in target. An explicit + * `providerInstances` entry wins; the legacy `providers.kimi` blob covers the + * synthesized default instance. `undefined` means the CLI default home. + */ +export function resolveKimiSignInHomePath( + settings: ServerSettings | undefined, + instanceId: ProviderInstanceId | undefined, +): string | undefined { + if (!settings) return undefined; + if (instanceId !== undefined) { + const instance = settings.providerInstances[instanceId]; + if (instance !== undefined && instance.driver === "kimi") { + const decoded = decodeKimiSettingsExit(instance.config ?? {}); + return Exit.isSuccess(decoded) ? decoded.value.homePath.trim() || undefined : undefined; + } + } + return settings.providers.kimi.homePath.trim() || undefined; +} + +function resolveOAuthHost(): string { + const override = + process.env.KIMI_CODE_OAUTH_HOST?.trim() || process.env.KIMI_OAUTH_HOST?.trim() || ""; + return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, ""); +} + +const DeviceAuthorizationResponse = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.optional(Schema.String), + verification_uri: Schema.optional(Schema.String), + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}); + +const TokenPollResponse = Schema.Struct({ + access_token: Schema.optional(Schema.String), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}); +type TokenPollResponse = typeof TokenPollResponse.Type; + +const postForm = Effect.fn("kimi.oauth.post_form")(function* ( + path: string, + params: Record, +) { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.post(`${resolveOAuthHost()}${path}`).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + ); + return response; +}); + +const requestDeviceAuthorization = Effect.fn("kimi.oauth.device_authorization")(function* () { + const response = yield* postForm(DEVICE_AUTHORIZATION_PATH, { + client_id: KIMI_OAUTH_CLIENT_ID, + }).pipe( + Effect.mapError( + (cause) => new KimiAuthError({ reason: "request-failed", detail: cause.message }), + ), + ); + if (response.status !== 200) { + return yield* new KimiAuthError({ + reason: "request-failed", + detail: `Device authorization failed (HTTP ${response.status}).`, + }); + } + return yield* HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)(response).pipe( + Effect.mapError( + (cause) => new KimiAuthError({ reason: "request-failed", detail: cause.message }), + ), + ); +}); + +const pollToken = Effect.fn("kimi.oauth.poll_token")( + function* (deviceCode: string) { + const response = yield* postForm(TOKEN_PATH, { + client_id: KIMI_OAUTH_CLIENT_ID, + device_code: deviceCode, + grant_type: DEVICE_CODE_GRANT, + }); + const body = yield* HttpClientResponse.schemaBodyJson(TokenPollResponse)(response); + return { status: response.status, body }; + }, + Effect.mapError( + (cause) => new KimiAuthError({ reason: "request-failed", detail: cause.message }), + ), +); + +/** kimi-cli's on-disk credential shape (`credentials/kimi-code.json`). */ +export function buildKimiCredentialsJson( + token: Pick< + TokenPollResponse, + "access_token" | "refresh_token" | "expires_in" | "scope" | "token_type" + >, + nowEpochMs: number, +): string { + const expiresIn = token.expires_in ?? 900; + return `${JSON.stringify( + { + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: nowEpochMs / 1000 + expiresIn, + scope: token.scope ?? "kimi-code", + token_type: token.token_type ?? "Bearer", + expires_in: expiresIn, + }, + null, + 2, + )}\n`; +} + +/** + * Persist the credential exactly where kimi-cli looks for it, atomically + * (tmp → rename) with owner-only permissions, matching the CLI's own writes. + */ +export const writeKimiCredentials = Effect.fn("kimi.oauth.write_credentials")(function* ( + homePath: string | null | undefined, + token: TokenPollResponse, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowEpochMs = yield* Clock.currentTimeMillis; + + const credentialsDir = path.join(resolveKimiCodeHome(homePath), CREDENTIALS_DIR_NAME); + const credentialsPath = path.join(credentialsDir, CREDENTIALS_FILE_NAME); + const temporaryPath = `${credentialsPath}.${process.pid}.${nowEpochMs}.tmp`; + + yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(credentialsDir, { recursive: true, mode: 0o700 }); + yield* fileSystem.writeFileString(temporaryPath, buildKimiCredentialsJson(token, nowEpochMs), { + mode: 0o600, + }); + yield* fileSystem.rename(temporaryPath, credentialsPath); + }).pipe( + Effect.tapError(() => Effect.ignore(fileSystem.remove(temporaryPath, { force: true }))), + Effect.mapError( + (cause) => new KimiAuthError({ reason: "credential-write-failed", detail: cause.message }), + ), + ); + + return credentialsPath; +}); + +export interface KimiSignInInput { + /** KIMI_CODE_HOME override from the target provider instance, if any. */ + readonly homePath?: string | null | undefined; +} + +/** + * Run one device-flow sign-in. Emits `verification` as soon as the user has + * something to open, then polls until approval and emits `completed` after + * the credential file is written. Interrupting the stream abandons the + * attempt without side effects. + */ +export function signInWithKimi( + input: KimiSignInInput, +): Stream.Stream< + KimiAuthSignInEvent, + KimiAuthError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path +> { + return Stream.unwrap( + Effect.gen(function* () { + const authorization = yield* requestDeviceAuthorization(); + const verificationUri = + authorization.verification_uri_complete?.trim() || + authorization.verification_uri?.trim() || + ""; + if (!verificationUri) { + return Stream.fail( + new KimiAuthError({ + reason: "request-failed", + detail: "Device authorization response carried no verification URI.", + }), + ); + } + const expiresInSeconds = authorization.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS; + const userCode = authorization.user_code?.trim(); + + const verificationEvent: KimiAuthSignInEvent = { + type: "verification", + verificationUri, + ...(userCode ? { userCode } : {}), + expiresInSeconds, + }; + + const completion = Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const deadlineMs = + startedAtMs + Math.min(expiresInSeconds * 1000, Duration.toMillis(MAX_SIGN_IN_DURATION)); + let intervalSeconds = Math.max(1, authorization.interval ?? DEFAULT_POLL_INTERVAL_SECONDS); + + while ((yield* Clock.currentTimeMillis) < deadlineMs) { + yield* Effect.sleep(Duration.seconds(intervalSeconds)); + const poll = yield* pollToken(authorization.device_code); + if (poll.status === 200 && poll.body.access_token) { + yield* writeKimiCredentials(input.homePath, poll.body); + return { type: "completed" } as const satisfies KimiAuthSignInEvent; + } + switch (poll.body.error) { + case "authorization_pending": + continue; + case "slow_down": + intervalSeconds += 5; + continue; + case "access_denied": + return yield* new KimiAuthError({ reason: "denied" }); + case "expired_token": + return yield* new KimiAuthError({ reason: "expired" }); + default: + return yield* new KimiAuthError({ + reason: "request-failed", + detail: + poll.body.error_description ?? + poll.body.error ?? + `Token polling failed (HTTP ${poll.status}).`, + }); + } + } + return yield* new KimiAuthError({ reason: "expired" }); + }); + + return Stream.concat(Stream.make(verificationEvent), Stream.fromEffect(completion)); + }), + ); +} diff --git a/apps/server/src/textGeneration/KimiTextGeneration.ts b/apps/server/src/textGeneration/KimiTextGeneration.ts new file mode 100644 index 000000000000..e2392d36eabd --- /dev/null +++ b/apps/server/src/textGeneration/KimiTextGeneration.ts @@ -0,0 +1,262 @@ +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 KimiSettings, 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 { + advertisedKimiModelIdsFromSessionSetup, + applyKimiAcpModelSelection, + currentKimiModelIdFromSessionSetup, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, +} from "../provider/acp/KimiAcpSupport.ts"; + +const KIMI_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeKimiTextGeneration = Effect.fn("makeKimiTextGeneration")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runKimiJson = ({ + 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 = resolveKimiAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeKimiAcpRuntime({ + kimiSettings, + 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* applyKimiAcpModelSelection({ + runtime, + currentModelId: currentKimiModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + advertisedModelIds: advertisedKimiModelIdsFromSessionSetup(started.sessionSetupResult), + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kimi ACP base model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(KIMI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Kimi ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kimi ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Kimi ACP request was cancelled." + : "Kimi CLI 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: "Kimi CLI returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kimi ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("KimiTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runKimiJson({ + 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("KimiTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runKimiJson({ + 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("KimiTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runKimiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("KimiTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runKimiJson({ + 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/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..ed271a20dd50 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,8 +3,10 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -48,6 +50,8 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + defaultInstanceIdForDriver, + ProviderDriverKind, RpcClientId, EnvironmentAuthorizationError, ThreadId, @@ -59,7 +63,12 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; -import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; +import { + HttpClient, + HttpRouter, + HttpServerRequest, + HttpServerRespondable, +} from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -79,6 +88,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as KimiOAuth from "./provider/kimi/KimiOAuth.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -399,6 +409,11 @@ const makeWsRpcLayer = ( ); const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; + // Captured for the Kimi sign-in stream, which runs the OAuth device + // flow over HTTP and writes the CLI credential file. + const kimiSignInHttpClient = yield* HttpClient.HttpClient; + const kimiSignInFileSystem = yield* FileSystem.FileSystem; + const kimiSignInPath = yield* Path.Path; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map( (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, @@ -1643,6 +1658,32 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.kimiAuthSignIn]: (input) => + observeRpcStreamEffect( + WS_METHODS.kimiAuthSignIn, + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.orElseSucceed(() => undefined), + ); + const homePath = KimiOAuth.resolveKimiSignInHomePath(settings, input.instanceId); + const refreshInstanceId = + input.instanceId ?? defaultInstanceIdForDriver(ProviderDriverKind.make("kimi")); + return KimiOAuth.signInWithKimi({ homePath }).pipe( + // A fresh credential flips the probe to authenticated; refresh + // eagerly so the UI reflects the sign-in without waiting for + // the periodic health check. + Stream.tap((event) => + event.type === "completed" + ? providerRegistry.refreshInstance(refreshInstanceId).pipe(Effect.ignore) + : Effect.void, + ), + Stream.provideService(HttpClient.HttpClient, kimiSignInHttpClient), + Stream.provideService(FileSystem.FileSystem, kimiSignInFileSystem), + Stream.provideService(Path.Path, kimiSignInPath), + ); + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.pullRequestsList]: (input) => observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..0515b7dfd7c3 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,21 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const KimiIcon: Icon = ({ className, ...props }) => ( + + {/* Stylized "K" mark: upright stem plus two angled strokes, with the + upper arm ending in Kimi's dot accent. */} + + + + +); + 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 842c616fe1fe..504c80be3cc0 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/KimiSignInControl.tsx b/apps/web/src/components/settings/KimiSignInControl.tsx new file mode 100644 index 000000000000..78c35d20a10b --- /dev/null +++ b/apps/web/src/components/settings/KimiSignInControl.tsx @@ -0,0 +1,86 @@ +import type { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { CheckIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; + +/** + * "Sign in with Kimi" affordance for Kimi provider instances. + * + * Runs the server-side OAuth device flow (`kimiAuth.signIn`) and renders its + * progress inline: a start button, then the verification link and user code + * while the server polls for approval, then a brief confirmation. The server + * refreshes the provider probe on success, so the surrounding card flips to + * authenticated on its own. + */ +export function KimiSignInControl({ + environmentId, + instanceId, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; +}) { + const signInState = useAtomValue(serverEnvironment.kimiSignInStateAtom(environmentId)); + const kimiSignIn = useAtomCommand(serverEnvironment.kimiSignIn, { reportFailure: false }); + const [isDispatching, setIsDispatching] = useState(false); + const dispatchingRef = useRef(false); + + const startSignIn = useCallback(() => { + if (dispatchingRef.current) return; + dispatchingRef.current = true; + setIsDispatching(true); + void kimiSignIn({ environmentId, input: { instanceId } }).finally(() => { + dispatchingRef.current = false; + setIsDispatching(false); + }); + }, [environmentId, instanceId, kimiSignIn]); + + if (signInState.status === "waiting") { + return ( +
+ + + Approve sign-in in your browser + + {signInState.userCode ? ( + + Code:{" "} + + {signInState.userCode} + + + ) : null} +
+ ); + } + + return ( +
+ + {signInState.status === "failed" ? ( + {signInState.message} + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a663aa90990d..2d57374fe2a7 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -342,6 +342,11 @@ interface ProviderInstanceCardProps { * omit it. */ readonly headerAction?: ReactNode | undefined; + /** + * Driver-specific authentication affordance rendered under the auth + * summary row (e.g. Kimi's in-app "Sign in with Kimi" device flow). + */ + readonly authAction?: ReactNode | undefined; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -384,6 +389,7 @@ export function ProviderInstanceCard({ onUpdate, onDelete, headerAction, + authAction, hiddenModels, favoriteModels, modelOrder, @@ -704,6 +710,7 @@ export function ProviderInstanceCard({ {titleTailNode} {authRowNode} + {authAction ?
{authAction}
: null}
+ ); + } if (signInState.status === "waiting") { return ( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 2b304378ae91..d5608c215c68 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -89,6 +89,7 @@ import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; const environmentId = EnvironmentId.make("remote-device"); const codexId = ProviderInstanceId.make("codex"); +const kimiId = ProviderInstanceId.make("kimi"); const customId = ProviderInstanceId.make("codex_work"); function provider(): ServerProvider { @@ -150,6 +151,30 @@ describe("EnvironmentProviderSettings routing", () => { expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); }); + it("does not render Kimi auth actions for an instance resolved as disabled", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [kimiId]: { + driver: ProviderDriverKind.make("kimi"), + config: { enabled: false }, + }, + }, + }; + atoms.providers = [ + { + ...provider(), + instanceId: kimiId, + driver: ProviderDriverKind.make("kimi"), + auth: { status: "unauthenticated" }, + }, + ]; + + const panel = renderPanel(); + const providerCard = visitElements(panel, (element) => element.props.instanceId === kimiId); + expect(providerCard?.props.authAction).toBeUndefined(); + }); + it("routes refresh and provider update commands to the selected environment", async () => { atoms.providers = [provider()]; const panel = renderPanel(); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 0900c2dcf06b..7647133f50db 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -835,10 +835,13 @@ export function EnvironmentProviderSettings({ ) : null; const authAction = row.driver === ProviderDriverKind.make("kimi") && - (row.instance.enabled ?? true) && - liveProvider?.installed === true && - liveProvider.auth.status !== "authenticated" ? ( - + resolveProviderInstanceEnabled(row.instance) && + liveProvider?.installed === true ? ( + ) : undefined; return ( { + it("keys sign-in state and single-flight lanes by environment and instance", () => { + const environment = EnvironmentId.make("environment-1"); + const otherEnvironment = EnvironmentId.make("environment-2"); + const personal = ProviderInstanceId.make("kimi_personal"); + const work = ProviderInstanceId.make("kimi_work"); + + expect(kimiAuthTargetKey(environment, personal)).toBe(kimiAuthTargetKey(environment, personal)); + expect(kimiAuthTargetKey(environment, personal)).not.toBe(kimiAuthTargetKey(environment, work)); + expect(kimiAuthTargetKey(environment, personal)).not.toBe( + kimiAuthTargetKey(otherEnvironment, personal), + ); + }); +}); + describe("update restart reconnect nudges", () => { it.effect("retries once per backoff entry instead of only the first", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index d1bf165c8b85..a36313e7a4ca 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1,5 +1,8 @@ import { + defaultInstanceIdForDriver, type EnvironmentId, + ProviderDriverKind, + type ProviderInstanceId, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -92,13 +95,27 @@ export interface KimiSignInTarget { readonly input: EnvironmentRpcInput; } +export interface KimiSignOutTarget { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; +} + +const DEFAULT_KIMI_INSTANCE_ID = defaultInstanceIdForDriver(ProviderDriverKind.make("kimi")); + +export function kimiAuthTargetKey( + environmentId: EnvironmentId, + instanceId: ProviderInstanceId, +): string { + return `${environmentId}\0${instanceId}`; +} + const IDLE_KIMI_SIGN_IN_STATE: KimiSignInState = { status: "idle" }; const EMPTY_KIMI_SIGN_IN_STATE_ATOM = Atom.make(IDLE_KIMI_SIGN_IN_STATE).pipe( Atom.withLabel("environment-data:server:kimi-sign-in-state:empty"), ); -const kimiSignInStateAtomFamily = Atom.family((environmentId: EnvironmentId) => +const kimiSignInStateAtomFamily = Atom.family((targetKey: string) => Atom.make(IDLE_KIMI_SIGN_IN_STATE).pipe( - Atom.withLabel(`environment-data:server:kimi-sign-in-state:${environmentId}`), + Atom.withLabel(`environment-data:server:kimi-sign-in-state:${targetKey}`), ), ); @@ -700,10 +717,13 @@ export function createServerEnvironmentAtoms( ); }, }); - const kimiSignInStateAtom = (environmentId: EnvironmentId | null) => + const kimiSignInStateAtom = ( + environmentId: EnvironmentId | null, + instanceId: ProviderInstanceId, + ) => environmentId === null ? EMPTY_KIMI_SIGN_IN_STATE_ATOM - : kimiSignInStateAtomFamily(environmentId); + : kimiSignInStateAtomFamily(kimiAuthTargetKey(environmentId, instanceId)); const kimiSignIn = createRuntimeCommand< EnvironmentRegistry | EnvironmentCacheStore | R, E, @@ -714,10 +734,16 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:kimi-sign-in", concurrency: { mode: "singleFlight", - key: ({ environmentId }) => environmentId, + key: ({ environmentId, input }) => + kimiAuthTargetKey(environmentId, input.instanceId ?? DEFAULT_KIMI_INSTANCE_ID), }, execute: (target, atomRegistry) => { - const stateAtom = kimiSignInStateAtomFamily(target.environmentId); + const stateAtom = kimiSignInStateAtomFamily( + kimiAuthTargetKey( + target.environmentId, + target.input.instanceId ?? DEFAULT_KIMI_INSTANCE_ID, + ), + ); atomRegistry.set(stateAtom, { status: "starting" }); return Effect.gen(function* () { const environmentRegistry = yield* EnvironmentRegistry; @@ -758,6 +784,38 @@ export function createServerEnvironmentAtoms( }, }); + const kimiSignOut = createRuntimeCommand< + EnvironmentRegistry | EnvironmentCacheStore | R, + E, + KimiSignOutTarget, + void, + unknown + >(runtime, { + label: "environment-data:server:kimi-sign-out", + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => + kimiAuthTargetKey(environmentId, input.instanceId ?? DEFAULT_KIMI_INSTANCE_ID), + }, + execute: (target, atomRegistry) => + Effect.gen(function* () { + const environmentRegistry = yield* EnvironmentRegistry; + yield* environmentRegistry.run( + target.environmentId, + request(WS_METHODS.kimiAuthSignOut, target.input), + ); + atomRegistry.set( + kimiSignInStateAtomFamily( + kimiAuthTargetKey( + target.environmentId, + target.input.instanceId ?? DEFAULT_KIMI_INSTANCE_ID, + ), + ), + IDLE_KIMI_SIGN_IN_STATE, + ); + }), + }); + const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => get(configValueAtom(environmentId))?.settings ?? null).pipe( Atom.withLabel(`environment-data:server:settings:${environmentId}`), @@ -776,6 +834,7 @@ export function createServerEnvironmentAtoms( providersValueAtom, kimiSignIn, kimiSignInStateAtom, + kimiSignOut, traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:trace-diagnostics", tag: WS_METHODS.serverGetTraceDiagnostics, From 9145cc5c78e6236256106076c998727b22760d40 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sat, 22 Aug 2026 22:37:35 +0500 Subject: [PATCH 16/22] fix(kimi): make terminal creation interruption-safe Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../acp/KimiAcpTerminalSupport.test.ts | 35 ++++ .../provider/acp/KimiAcpTerminalSupport.ts | 189 +++++++++--------- 2 files changed, 133 insertions(+), 91 deletions(-) diff --git a/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts b/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts index 21df53a2509b..ad5821f085fc 100644 --- a/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts +++ b/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts @@ -269,6 +269,41 @@ describe("KimiAcpTerminalSupport", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("interrupting a spawned creation closes its scope and clears pending ownership", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnCompleted = yield* Deferred.make(); + const holdRegistration = yield* Deferred.make(); + const finalizerRan = yield* Deferred.make(); + const manager = yield* makeKimiAcpTerminalManager({ + childProcessSpawner: { + ...childProcessSpawner, + spawn: (command) => + Effect.gen(function* () { + const handle = yield* childProcessSpawner.spawn(command); + yield* Effect.addFinalizer(() => + Deferred.succeed(finalizerRan, undefined).pipe(Effect.asVoid), + ); + yield* Deferred.succeed(spawnCompleted, undefined); + yield* Deferred.await(holdRegistration); + return handle; + }), + }, + }); + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const createFiber = yield* manager + .handleCreateTerminal({ sessionId: SESSION_ID, command, args }) + .pipe(Effect.forkChild); + + yield* Deferred.await(spawnCompleted); + yield* Fiber.interrupt(createFiber); + yield* Deferred.await(finalizerRan); + assert.isTrue(yield* Deferred.isDone(finalizerRan)); + yield* manager.killAll; + yield* manager.shutdown; + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("shutdown kills open terminals and forgets them", () => withTerminalManager((manager) => Effect.gen(function* () { diff --git a/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts b/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts index a6436e8a0946..def4ed63fbce 100644 --- a/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts +++ b/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts @@ -171,103 +171,110 @@ export const makeKimiAcpTerminalManager = (input: { }); const handleCreateTerminal: KimiAcpTerminalManager["handleCreateTerminal"] = (request) => - Effect.gen(function* () { - const terminalId = `term-${++nextTerminalId}`; - const scope = yield* Scope.make(); - const pendingCreation: KimiPendingTerminalCreation = { - scope, - killRequested: false, - disposeRequested: false, - }; - pendingCreations.set(terminalId, pendingCreation); - const env = request.env - ? Object.fromEntries(request.env.map((entry) => [entry.name, entry.value])) - : undefined; - // Kimi sends an absolute command path (its Git Bash wrapper on - // Windows), so no shell resolution is involved. - const handle = yield* input.childProcessSpawner - .spawn( - ChildProcess.make(request.command, request.args ?? [], { - ...(request.cwd ? { cwd: request.cwd } : {}), - ...(env ? { env, extendEnv: true } : {}), - stdin: "ignore", - // Kill escalation for release/shutdown of commands that ignore - // the default termination signal. - forceKillAfter: "5 seconds", - }), - ) - .pipe( - Effect.provideService(Scope.Scope, scope), - Effect.onError(() => - Effect.sync(() => pendingCreations.delete(terminalId)).pipe( - Effect.andThen(Effect.ignore(Scope.close(scope, Exit.void))), - ), - ), - Effect.mapError((cause) => - EffectAcpErrors.AcpRequestError.internalError( - `Failed to spawn terminal command '${request.command}'.`, - undefined, - { method: "terminal/create", cause }, - ), + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const terminalId = `term-${++nextTerminalId}`; + const scope = yield* Scope.make(); + const pendingCreation: KimiPendingTerminalCreation = { + scope, + killRequested: false, + disposeRequested: false, + }; + pendingCreations.set(terminalId, pendingCreation); + const env = request.env + ? Object.fromEntries(request.env.map((entry) => [entry.name, entry.value])) + : undefined; + // Kimi sends an absolute command path (its Git Bash wrapper on + // Windows), so no shell resolution is involved. + const handleExit = yield* Effect.exit( + restore( + input.childProcessSpawner + .spawn( + ChildProcess.make(request.command, request.args ?? [], { + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(env ? { env, extendEnv: true } : {}), + stdin: "ignore", + // Kill escalation for release/shutdown of commands that ignore + // the default termination signal. + forceKillAfter: "5 seconds", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.internalError( + `Failed to spawn terminal command '${request.command}'.`, + undefined, + { method: "terminal/create", cause }, + ), + ), + ), ), ); + if (Exit.isFailure(handleExit)) { + pendingCreations.delete(terminalId); + yield* Effect.ignore(Scope.close(scope, Exit.void)); + return yield* Effect.failCause(handleExit.cause); + } + const handle = handleExit.value; - const buffer: KimiTerminalOutputBuffer = { - output: "", - outputBytes: 0, - truncated: false, - byteLimit: Math.max(0, request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT), - }; - const exit = yield* Deferred.make(); + const buffer: KimiTerminalOutputBuffer = { + output: "", + outputBytes: 0, + truncated: false, + byteLimit: Math.max(0, request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT), + }; + const exit = yield* Deferred.make(); - // One streaming decoder per stream so interleaving cannot split a - // multi-byte character across decode calls. - const drainStream = (stream: typeof handle.stdout): Effect.Effect => - Effect.suspend(() => { - const decoder = new TextDecoder("utf-8"); - return Stream.runForEach(stream, (chunk) => - Effect.sync(() => - appendTerminalOutput(buffer, decoder.decode(chunk, { stream: true })), - ), - ).pipe( - Effect.ensuring(Effect.sync(() => appendTerminalOutput(buffer, decoder.decode()))), - Effect.ignore, - ); - }); - - const stdoutFiber = yield* drainStream(handle.stdout).pipe(Effect.forkIn(scope)); - const stderrFiber = yield* drainStream(handle.stderr).pipe(Effect.forkIn(scope)); - yield* handle.exitCode.pipe( - Effect.matchEffect({ - onSuccess: (exitCode) => Deferred.succeed(exit, { exitCode, signal: null }), - onFailure: (error) => Deferred.succeed(exit, exitFromSignalFailure(error)), - }), - Effect.forkIn(scope), - ); + // One streaming decoder per stream so interleaving cannot split a + // multi-byte character across decode calls. + const drainStream = (stream: typeof handle.stdout): Effect.Effect => + Effect.suspend(() => { + const decoder = new TextDecoder("utf-8"); + return Stream.runForEach(stream, (chunk) => + Effect.sync(() => + appendTerminalOutput(buffer, decoder.decode(chunk, { stream: true })), + ), + ).pipe( + Effect.ensuring(Effect.sync(() => appendTerminalOutput(buffer, decoder.decode()))), + Effect.ignore, + ); + }); - const terminalState: KimiTerminalState = { - scope, - buffer, - exit, - drainFibers: [stdoutFiber, stderrFiber], - kill: Effect.ignore(handle.kill()), - }; - terminals.set(terminalId, terminalState); - pendingCreations.delete(terminalId); - if (pendingCreation.disposeRequested) { - yield* disposeTerminal(terminalId, terminalState); - return yield* EffectAcpErrors.AcpRequestError.internalError( - "Terminal creation was cancelled because the Kimi session stopped.", - undefined, - { method: "terminal/create" }, + const stdoutFiber = yield* drainStream(handle.stdout).pipe(Effect.forkIn(scope)); + const stderrFiber = yield* drainStream(handle.stderr).pipe(Effect.forkIn(scope)); + yield* handle.exitCode.pipe( + Effect.matchEffect({ + onSuccess: (exitCode) => Deferred.succeed(exit, { exitCode, signal: null }), + onFailure: (error) => Deferred.succeed(exit, exitFromSignalFailure(error)), + }), + Effect.forkIn(scope), ); - } - if (pendingCreation.killRequested) { - yield* Deferred.succeed(exit, { exitCode: null, signal: "SIGTERM" }); - yield* terminalState.kill; - } - return { terminalId } satisfies EffectAcpSchema.CreateTerminalResponse; - }); + + const terminalState: KimiTerminalState = { + scope, + buffer, + exit, + drainFibers: [stdoutFiber, stderrFiber], + kill: Effect.ignore(handle.kill()), + }; + terminals.set(terminalId, terminalState); + pendingCreations.delete(terminalId); + if (pendingCreation.disposeRequested) { + yield* disposeTerminal(terminalId, terminalState); + return yield* EffectAcpErrors.AcpRequestError.internalError( + "Terminal creation was cancelled because the Kimi session stopped.", + undefined, + { method: "terminal/create" }, + ); + } + if (pendingCreation.killRequested) { + yield* Deferred.succeed(exit, { exitCode: null, signal: "SIGTERM" }); + yield* terminalState.kill; + } + return { terminalId } satisfies EffectAcpSchema.CreateTerminalResponse; + }), + ); const handleTerminalOutput: KimiAcpTerminalManager["handleTerminalOutput"] = (request) => Effect.gen(function* () { From 4c2c0c27811fa884a444add116daf53cfda64d22 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sat, 22 Aug 2026 22:37:59 +0500 Subject: [PATCH 17/22] fix(web): announce Kimi sign-in status Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../settings/KimiSignInControl.test.tsx | 18 ++++++++++++++++++ .../components/settings/KimiSignInControl.tsx | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/KimiSignInControl.test.tsx b/apps/web/src/components/settings/KimiSignInControl.test.tsx index 652b5148759d..81b7c91cae18 100644 --- a/apps/web/src/components/settings/KimiSignInControl.test.tsx +++ b/apps/web/src/components/settings/KimiSignInControl.test.tsx @@ -96,9 +96,27 @@ describe("KimiSignInControl", () => { expect( visitElements(personal, (element) => element.props.href === "https://auth.example/personal"), ).not.toBeNull(); + const liveStatus = visitElements(personal, (element) => element.props.role === "status"); + expect(liveStatus?.props["aria-live"]).toBe("polite"); expect(visitElements(work, (element) => element.props.href !== undefined)).toBeNull(); }); + it("announces sign-in failures as a polite live status", () => { + state.values.set(`${environmentId}:${workId}`, { + status: "failed", + message: "Kimi sign-in failed.", + }); + + const control = renderControl(workId); + const failure = visitElements( + control, + (element) => element.props.role === "status" && element.props["aria-live"] === "polite", + ); + + expect(failure).not.toBeNull(); + expect(failure?.props.children).toBe("Kimi sign-in failed."); + }); + it("signs out the authenticated provider instance", async () => { const control = renderControl(workId, true); const button = visitElements(control, (element) => typeof element.props.onClick === "function"); diff --git a/apps/web/src/components/settings/KimiSignInControl.tsx b/apps/web/src/components/settings/KimiSignInControl.tsx index c925a6bc0cd7..74c03a6d9513 100644 --- a/apps/web/src/components/settings/KimiSignInControl.tsx +++ b/apps/web/src/components/settings/KimiSignInControl.tsx @@ -69,7 +69,11 @@ export function KimiSignInControl({ if (signInState.status === "waiting") { return ( -
+ ); From be96ae6ba336e54bfb6ade32f3c796e8ed6aa6f0 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sat, 22 Aug 2026 22:38:22 +0500 Subject: [PATCH 18/22] fix(kimi): model authentication failures explicitly Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/provider/kimi/KimiOAuth.test.ts | 58 +++++++- apps/server/src/provider/kimi/KimiOAuth.ts | 98 ++++++------- apps/server/src/server.test.ts | 7 +- apps/server/src/ws.ts | 12 +- packages/contracts/src/kimiAuth.test.ts | 55 ++++++- packages/contracts/src/kimiAuth.ts | 134 +++++++++++++----- 6 files changed, 254 insertions(+), 110 deletions(-) diff --git a/apps/server/src/provider/kimi/KimiOAuth.test.ts b/apps/server/src/provider/kimi/KimiOAuth.test.ts index b7885df157d6..b5f0a1bca08d 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.test.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.test.ts @@ -1,6 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { KimiAuthError, ProviderInstanceId, ServerSettings } from "@t3tools/contracts"; +import { + KimiAuthDeniedError, + KimiAuthExpiredError, + KimiAuthInstanceInvalidError, + KimiAuthRequestError, + ProviderInstanceId, + ServerSettings, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -108,8 +115,10 @@ describe("resolveKimiSignInHomePath", () => { ProviderInstanceId.make("codex_work"), ).pipe(Effect.flip); - expect(missing.reason).toBe("invalid-instance"); - expect(wrongDriver.reason).toBe("invalid-instance"); + expect(missing).toBeInstanceOf(KimiAuthInstanceInvalidError); + expect(missing.issue).toBe("not-found"); + expect(wrongDriver).toBeInstanceOf(KimiAuthInstanceInvalidError); + expect(wrongDriver.issue).toBe("wrong-driver"); }), ); @@ -245,8 +254,7 @@ it.layer(NodeServices.layer)("signInWithKimi", (it) => { yield* TestClock.adjust("5 seconds"); const error = yield* Fiber.join(outcome); - expect(error).toBeInstanceOf(KimiAuthError); - expect(error.reason).toBe("denied"); + expect(error).toBeInstanceOf(KimiAuthDeniedError); }), ); @@ -271,13 +279,49 @@ it.layer(NodeServices.layer)("signInWithKimi", (it) => { yield* TestClock.adjust("1 second"); const error = yield* Fiber.join(outcome); - expect(error.reason).toBe("expired"); + expect(error).toBeInstanceOf(KimiAuthExpiredError); expect(requests.filter((request) => request.url.includes("/api/oauth/token"))).toHaveLength( 0, ); }), ); + it.effect("keeps raw OAuth wire failures out of stable error context", () => + Effect.gen(function* () { + const rawDescription = "x".repeat(10_000); + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { + status: 400, + body: { error: "vendor_secret_code", error_description: rawDescription }, + }, + ); + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthRequestError); + expect(error._tag).toBe("KimiAuthRequestError"); + if (error._tag === "KimiAuthRequestError") { + expect(error.operation).toBe("token-poll"); + expect(error.status).toBe(400); + expect(error.oauthErrorCode).toBeUndefined(); + expect(error).not.toHaveProperty("detail"); + expect((error.cause as { errorDescription?: string }).errorDescription).toBe( + rawDescription, + ); + } + }), + ); + it.effect("fails with `expired` when the device authorization lapses", () => Effect.gen(function* () { const requests: Array = []; @@ -296,7 +340,7 @@ it.layer(NodeServices.layer)("signInWithKimi", (it) => { yield* TestClock.adjust("5 seconds"); const error = yield* Fiber.join(outcome); - expect(error.reason).toBe("expired"); + expect(error).toBeInstanceOf(KimiAuthExpiredError); }), ); }); diff --git a/apps/server/src/provider/kimi/KimiOAuth.ts b/apps/server/src/provider/kimi/KimiOAuth.ts index fe1fc5338511..07cf69b85d50 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.ts @@ -16,8 +16,15 @@ import * as NodeOS from "node:os"; import { - KimiAuthError, + KimiAuthDeniedError, + type KimiAuthError, + KimiAuthExpiredError, + KimiAuthInstanceInvalidError, + KimiAuthRequestError, + KimiCredentialRemoveError, + KimiCredentialWriteError, type KimiAuthSignInEvent, + KimiOAuthErrorCode, KimiSettings, defaultInstanceIdForDriver, ProviderDriverKind, @@ -76,16 +83,16 @@ export const resolveKimiAuthTarget = Effect.fn("kimi.oauth.resolve_target")(func const instance = settings.providerInstances[targetInstanceId]; if (instance !== undefined) { if (instance.driver !== KIMI_DRIVER) { - return yield* new KimiAuthError({ - reason: "invalid-instance", - detail: `Provider instance '${targetInstanceId}' is not a Kimi instance.`, + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "wrong-driver", }); } const decoded = decodeKimiSettingsExit(instance.config ?? {}); if (Exit.isFailure(decoded)) { - return yield* new KimiAuthError({ - reason: "invalid-instance", - detail: `Provider instance '${targetInstanceId}' has invalid Kimi settings.`, + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "invalid-settings", cause: decoded.cause, }); } @@ -95,9 +102,9 @@ export const resolveKimiAuthTarget = Effect.fn("kimi.oauth.resolve_target")(func } satisfies KimiAuthTarget; } if (targetInstanceId !== DEFAULT_KIMI_INSTANCE_ID) { - return yield* new KimiAuthError({ - reason: "invalid-instance", - detail: `Kimi provider instance '${targetInstanceId}' was not found.`, + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "not-found", }); } return { @@ -138,6 +145,7 @@ const TokenPollResponse = Schema.Struct({ error_description: Schema.optional(Schema.String), }); type TokenPollResponse = typeof TokenPollResponse.Type; +const isKimiOAuthErrorCode = Schema.is(KimiOAuthErrorCode); const postForm = Effect.fn("kimi.oauth.post_form")(function* ( path: string, @@ -158,25 +166,23 @@ const requestDeviceAuthorization = Effect.fn("kimi.oauth.device_authorization")( }).pipe( Effect.mapError( (cause) => - new KimiAuthError({ - reason: "request-failed", - detail: "Failed to request Kimi device authorization.", + new KimiAuthRequestError({ + operation: "device-authorization-request", cause, }), ), ); if (response.status !== 200) { - return yield* new KimiAuthError({ - reason: "request-failed", - detail: `Device authorization failed (HTTP ${response.status}).`, + return yield* new KimiAuthRequestError({ + operation: "device-authorization-request", + status: response.status, }); } return yield* HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)(response).pipe( Effect.mapError( (cause) => - new KimiAuthError({ - reason: "request-failed", - detail: "Kimi device authorization returned an invalid response.", + new KimiAuthRequestError({ + operation: "device-authorization-response", cause, }), ), @@ -195,9 +201,8 @@ const pollToken = Effect.fn("kimi.oauth.poll_token")( }, Effect.mapError( (cause) => - new KimiAuthError({ - reason: "request-failed", - detail: "Failed to poll Kimi device authorization.", + new KimiAuthRequestError({ + operation: "token-poll", cause, }), ), @@ -250,14 +255,7 @@ export const writeKimiCredentials = Effect.fn("kimi.oauth.write_credentials")(fu yield* fileSystem.rename(temporaryPath, credentialsPath); }).pipe( Effect.tapError(() => Effect.ignore(fileSystem.remove(temporaryPath, { force: true }))), - Effect.mapError( - (cause) => - new KimiAuthError({ - reason: "credential-write-failed", - detail: "Failed to write the Kimi credential file.", - cause, - }), - ), + Effect.mapError((cause) => new KimiCredentialWriteError({ credentialsPath, cause })), ); return credentialsPath; @@ -273,16 +271,9 @@ export const removeKimiCredentials = Effect.fn("kimi.oauth.remove_credentials")( CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME, ); - yield* fileSystem.remove(credentialsPath, { force: true }).pipe( - Effect.mapError( - (cause) => - new KimiAuthError({ - reason: "credential-remove-failed", - detail: "Failed to remove the Kimi credential file.", - cause, - }), - ), - ); + yield* fileSystem + .remove(credentialsPath, { force: true }) + .pipe(Effect.mapError((cause) => new KimiCredentialRemoveError({ credentialsPath, cause }))); return credentialsPath; }); @@ -313,9 +304,8 @@ export function signInWithKimi( ""; if (!verificationUri) { return Stream.fail( - new KimiAuthError({ - reason: "request-failed", - detail: "Device authorization response carried no verification URI.", + new KimiAuthRequestError({ + operation: "device-authorization-response", }), ); } @@ -353,20 +343,24 @@ export function signInWithKimi( intervalSeconds += 5; continue; case "access_denied": - return yield* new KimiAuthError({ reason: "denied" }); + return yield* new KimiAuthDeniedError(); case "expired_token": - return yield* new KimiAuthError({ reason: "expired" }); + return yield* new KimiAuthExpiredError(); default: - return yield* new KimiAuthError({ - reason: "request-failed", - detail: - poll.body.error_description ?? - poll.body.error ?? - `Token polling failed (HTTP ${poll.status}).`, + return yield* new KimiAuthRequestError({ + operation: "token-poll", + status: poll.status, + ...(isKimiOAuthErrorCode(poll.body.error) + ? { oauthErrorCode: poll.body.error } + : {}), + cause: { + error: poll.body.error, + errorDescription: poll.body.error_description, + }, }); } } - return yield* new KimiAuthError({ reason: "expired" }); + return yield* new KimiAuthExpiredError(); }); return Stream.concat(Stream.make(verificationEvent), Stream.fromEffect(completion)); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ab8d62105d5b..5a7f741bf36e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4452,10 +4452,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(error._tag, "KimiAuthError"); - if (error._tag === "KimiAuthError") { - assert.equal(error.reason, "request-failed"); - assert.include(error.detail ?? "", "provider settings"); + assert.equal(error._tag, "KimiAuthRequestError"); + if (error._tag === "KimiAuthRequestError") { + assert.equal(error.operation, "provider-settings"); } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f54d8888902..b42e57681efd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -52,7 +52,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, - KimiAuthError, + KimiAuthRequestError, RpcClientId, EnvironmentAuthorizationError, ThreadId, @@ -1744,9 +1744,8 @@ const makeWsRpcLayer = ( const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( (cause) => - new KimiAuthError({ - reason: "request-failed", - detail: "Failed to load provider settings for Kimi sign-in.", + new KimiAuthRequestError({ + operation: "provider-settings", cause, }), ), @@ -1775,9 +1774,8 @@ const makeWsRpcLayer = ( const settings = yield* serverSettings.getSettings.pipe( Effect.mapError( (cause) => - new KimiAuthError({ - reason: "request-failed", - detail: "Failed to load provider settings for Kimi sign-out.", + new KimiAuthRequestError({ + operation: "provider-settings", cause, }), ), diff --git a/packages/contracts/src/kimiAuth.test.ts b/packages/contracts/src/kimiAuth.test.ts index 829e3f23752d..f5a43c6d54e9 100644 --- a/packages/contracts/src/kimiAuth.test.ts +++ b/packages/contracts/src/kimiAuth.test.ts @@ -1,16 +1,57 @@ import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; -import { KimiAuthError } from "./kimiAuth.ts"; +import { + KimiAuthDeniedError, + KimiAuthError, + KimiAuthExpiredError, + KimiAuthInstanceInvalidError, + KimiAuthRequestError, + KimiCredentialRemoveError, + KimiCredentialWriteError, +} from "./kimiAuth.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; -it("preserves the underlying cause separately from stable context", () => { +const isKimiAuthError = Schema.is(KimiAuthError); + +it("preserves request causes separately from bounded context", () => { const cause = new Error("sensitive transport detail"); - const error = new KimiAuthError({ - reason: "request-failed", - detail: "Failed to request Kimi device authorization.", + const error = new KimiAuthRequestError({ + operation: "token-poll", + status: 503, + oauthErrorCode: "temporarily_unavailable", cause, }); - expect(error.detail).toBe("Failed to request Kimi device authorization."); + expect(error.status).toBe(503); + expect(error.oauthErrorCode).toBe("temporarily_unavailable"); expect(error.cause).toBe(cause); - expect(error.message).toBe("Kimi sign-in failed."); + expect(error.message).toBe("Failed to poll Kimi device authorization."); + expect(isKimiAuthError(error)).toBe(true); +}); + +it("uses distinct tagged errors for distinct authentication failures", () => { + const instanceId = ProviderInstanceId.make("kimi_work"); + const errors = [ + new KimiAuthDeniedError(), + new KimiAuthExpiredError(), + new KimiCredentialWriteError({ + credentialsPath: "/tmp/kimi-code.json", + cause: new Error("write failed"), + }), + new KimiCredentialRemoveError({ + credentialsPath: "/tmp/kimi-code.json", + cause: new Error("remove failed"), + }), + new KimiAuthInstanceInvalidError({ instanceId, issue: "not-found" }), + ]; + + expect(errors.map((error) => error._tag)).toEqual([ + "KimiAuthDeniedError", + "KimiAuthExpiredError", + "KimiCredentialWriteError", + "KimiCredentialRemoveError", + "KimiAuthInstanceInvalidError", + ]); + expect(errors.every(isKimiAuthError)).toBe(true); }); diff --git a/packages/contracts/src/kimiAuth.ts b/packages/contracts/src/kimiAuth.ts index 19b22bc9121b..88a30a57bcf6 100644 --- a/packages/contracts/src/kimiAuth.ts +++ b/packages/contracts/src/kimiAuth.ts @@ -52,42 +52,110 @@ export const KimiAuthSignInEvent = Schema.Union([ ]); export type KimiAuthSignInEvent = typeof KimiAuthSignInEvent.Type; -export const KimiAuthFailureReason = Schema.Literals([ - // The user rejected the sign-in on the verification page. - "denied", - // The device authorization expired before the user approved it. - "expired", - // Requesting or polling the OAuth endpoints failed. - "request-failed", - // The token arrived but persisting the credentials file failed. - "credential-write-failed", - // Removing an existing credential during sign-out failed. - "credential-remove-failed", - // The requested provider instance is missing, invalid, or not Kimi. - "invalid-instance", +export const KimiOAuthErrorCode = Schema.Literals([ + "authorization_pending", + "slow_down", + "access_denied", + "expired_token", + "invalid_request", + "invalid_client", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", + "server_error", + "temporarily_unavailable", ]); -export type KimiAuthFailureReason = typeof KimiAuthFailureReason.Type; +export type KimiOAuthErrorCode = typeof KimiOAuthErrorCode.Type; -export class KimiAuthError extends Schema.TaggedErrorClass()("KimiAuthError", { - reason: KimiAuthFailureReason, - detail: Schema.optional(Schema.String), - cause: Schema.optional(Schema.Defect()), -}) { +export class KimiAuthDeniedError extends Schema.TaggedErrorClass()( + "KimiAuthDeniedError", + {}, +) { override get message(): string { - switch (this.reason) { - case "denied": - return "Kimi sign-in was denied."; - case "expired": - return "Kimi sign-in expired before it was approved."; - case "credential-write-failed": - return "Kimi sign-in succeeded but the credential could not be saved."; - case "credential-remove-failed": - return "Kimi credentials could not be removed."; - case "invalid-instance": - return "The selected Kimi provider instance is unavailable."; - case "request-failed": - default: - return "Kimi sign-in failed."; + return "Kimi sign-in was denied."; + } +} + +export class KimiAuthExpiredError extends Schema.TaggedErrorClass()( + "KimiAuthExpiredError", + {}, +) { + override get message(): string { + return "Kimi sign-in expired before it was approved."; + } +} + +export class KimiAuthRequestError extends Schema.TaggedErrorClass()( + "KimiAuthRequestError", + { + operation: Schema.Literals([ + "provider-settings", + "device-authorization-request", + "device-authorization-response", + "token-poll", + ]), + status: Schema.optional(Schema.Number), + oauthErrorCode: Schema.optional(KimiOAuthErrorCode), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.operation) { + case "provider-settings": + return "Failed to load provider settings for Kimi authentication."; + case "device-authorization-request": + return "Failed to request Kimi device authorization."; + case "device-authorization-response": + return "Kimi device authorization returned an invalid response."; + case "token-poll": + return "Failed to poll Kimi device authorization."; } } } + +export class KimiCredentialWriteError extends Schema.TaggedErrorClass()( + "KimiCredentialWriteError", + { + credentialsPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Kimi sign-in succeeded but the credential could not be saved."; + } +} + +export class KimiCredentialRemoveError extends Schema.TaggedErrorClass()( + "KimiCredentialRemoveError", + { + credentialsPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Kimi credentials could not be removed."; + } +} + +export class KimiAuthInstanceInvalidError extends Schema.TaggedErrorClass()( + "KimiAuthInstanceInvalidError", + { + instanceId: ProviderInstanceId, + issue: Schema.Literals(["not-found", "wrong-driver", "invalid-settings"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "The selected Kimi provider instance is unavailable."; + } +} + +export const KimiAuthError = Schema.Union([ + KimiAuthDeniedError, + KimiAuthExpiredError, + KimiAuthRequestError, + KimiCredentialWriteError, + KimiCredentialRemoveError, + KimiAuthInstanceInvalidError, +]); +export type KimiAuthError = typeof KimiAuthError.Type; From 21ca8992e002aa89f5faa8d25ff78ee511a7b6d3 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sat, 22 Aug 2026 23:01:28 +0500 Subject: [PATCH 19/22] fix(kimi): settle prompt slots when a queued steer fiber is dropped A steer waiting on the prompt permit that lost its request fiber (client disconnect during Stop) skipped the runPrompt finalizer entirely, leaving promptsInFlight raised and the turn-activity tracker active, which deferred provider probes until the session stopped. The runPrompt finalizer now marks the slot settled on every path and a queued-interrupt compensation releases the slot, settling the merged turn with a cancelled completion when it was the last one. Found by Devin Review on the fork staging PR. Implemented by Claude via Cursor. --- .../src/provider/Layers/KimiAdapter.test.ts | 83 +++++++++++++++++++ .../server/src/provider/Layers/KimiAdapter.ts | 31 ++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/KimiAdapter.test.ts b/apps/server/src/provider/Layers/KimiAdapter.test.ts index f5fa2f5a0e94..014711331fee 100644 --- a/apps/server/src/provider/Layers/KimiAdapter.test.ts +++ b/apps/server/src/provider/Layers/KimiAdapter.test.ts @@ -880,6 +880,89 @@ it.layer(kimiAdapterTestLayer)("KimiAdapterLive", (it) => { }), ); + it.effect("settles the prompt slot when a queued steer fiber is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-queued-steer-fiber-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const baseActivity = yield* makeKimiTurnActivity; + const activityMarks = yield* Ref.make(0); + const twoPromptsActive = yield* Deferred.make(); + const turnActivity: KimiTurnActivity = { + ...baseActivity, + markActive: (activeThreadId) => + baseActivity + .markActive(activeThreadId) + .pipe( + Effect.andThen( + Ref.updateAndGet(activityMarks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Deferred.succeed(twoPromptsActive, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + ), + }; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const firstRequest = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") { + yield* Deferred.succeed(firstRequest, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ threadId, input: "first prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstOpened = yield* Deferred.await(firstRequest).pipe(Effect.timeout("5 seconds")); + const steerFiber = yield* adapter + .sendTurn({ threadId, input: "steer prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(twoPromptsActive).pipe(Effect.timeout("5 seconds")); + + // The steer is prepared (slot raised) but queued behind the running + // prompt's permit. Dropping its fiber here used to leak the slot, so + // the merged turn could never settle and probe deferral stuck forever. + // Let the steer preparation finish and queue on the prompt permit + // before dropping its fiber. + for (let yieldAttempt = 0; yieldAttempt < 64; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + // Forked interrupt: it must not block the test if the permit + // acquisition itself is not interruptible. + yield* Fiber.interrupt(steerFiber).pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(String(firstOpened.requestId)), "accept") + .pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + yield* Fiber.await(steerFiber).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("does not run a queued steer after the active turn is interrupted", () => Effect.gen(function* () { const threadId = ThreadId.make("kimi-queued-steer-after-interrupt"); diff --git a/apps/server/src/provider/Layers/KimiAdapter.ts b/apps/server/src/provider/Layers/KimiAdapter.ts index 09b4c816fd7f..176dd9307567 100644 --- a/apps/server/src/provider/Layers/KimiAdapter.ts +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -1375,10 +1375,39 @@ export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapte errorMessage: errorMessage ?? "Kimi prompt request failed.", }), ); + }).pipe( + // Every path through this finalizer accounts for the prompt + // slot, so the queued-interrupt compensation below must no-op. + Effect.ensuring(Ref.set(promptSettled, true)), + Effect.catch(() => Effect.void), + ), + ), + ); + // The finalizer above only runs once runPrompt has started, which + // requires holding the permit. A fiber interrupted while still QUEUED + // on the semaphore (a steer waiting behind an active prompt when the + // client drops the request) would skip cleanup entirely, leaving + // promptsInFlight raised and the turn-activity tracker marked active, + // which defers provider probes until the session stops. + return yield* prepared.promptSemaphore.withPermit(runPrompt).pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + yield* Ref.set(promptSettled, true); + // With another prompt still in flight this only releases the + // slot; if the dropped steer was the last slot it settles the + // merged turn with an honest cancelled completion. + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + completedStopReason: "cancelled", + }), + ); }).pipe(Effect.catch(() => Effect.void)), ), ); - return yield* prepared.promptSemaphore.withPermit(runPrompt); }); const interruptTurn: KimiAdapterShape["interruptTurn"] = (threadId, turnId) => From 77c5ab5898fd65971487da1a789f375cfb8ad842 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sun, 23 Aug 2026 13:46:28 +0500 Subject: [PATCH 20/22] fix(kimi): isolate concurrent credential writes Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/provider/kimi/KimiOAuth.test.ts | 34 +++++++++++++++++++ apps/server/src/provider/kimi/KimiOAuth.ts | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/kimi/KimiOAuth.test.ts b/apps/server/src/provider/kimi/KimiOAuth.test.ts index b5f0a1bca08d..ab7efe718406 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.test.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.test.ts @@ -13,6 +13,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -369,6 +370,39 @@ it.layer(NodeServices.layer)("writeKimiCredentials", (it) => { }), ); + it.effect("uses unique temporary files for concurrent credential writes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-concurrent-creds-" }); + const temporaryPaths = yield* Ref.make>([]); + const recordingFileSystem: FileSystem.FileSystem["Service"] = { + ...fs, + writeFileString: (filePath) => + Ref.update(temporaryPaths, (current) => [...current, filePath]), + rename: () => Effect.void, + }; + + const paths = yield* Effect.all( + [ + writeKimiCredentials(home, { + access_token: "access-1", + refresh_token: "refresh-1", + }), + writeKimiCredentials(home, { + access_token: "access-2", + refresh_token: "refresh-2", + }), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.provideService(FileSystem.FileSystem, recordingFileSystem)); + const observedTemporaryPaths = yield* Ref.get(temporaryPaths); + + expect(paths[0]).toBe(paths[1]); + expect(observedTemporaryPaths).toHaveLength(2); + expect(new Set(observedTemporaryPaths).size).toBe(2); + }), + ); + it.effect("removes only the targeted instance credential file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/kimi/KimiOAuth.ts b/apps/server/src/provider/kimi/KimiOAuth.ts index 07cf69b85d50..0f9b6caa179d 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.ts @@ -13,6 +13,7 @@ * * @module provider/kimi/KimiOAuth */ +import * as NodeCrypto from "node:crypto"; import * as NodeOS from "node:os"; import { @@ -245,7 +246,7 @@ export const writeKimiCredentials = Effect.fn("kimi.oauth.write_credentials")(fu const credentialsDir = path.join(resolveKimiCodeHome(homePath), CREDENTIALS_DIR_NAME); const credentialsPath = path.join(credentialsDir, CREDENTIALS_FILE_NAME); - const temporaryPath = `${credentialsPath}.${process.pid}.${nowEpochMs}.tmp`; + const temporaryPath = `${credentialsPath}.${process.pid}.${nowEpochMs}.${NodeCrypto.randomUUID()}.tmp`; yield* Effect.gen(function* () { yield* fileSystem.makeDirectory(credentialsDir, { recursive: true, mode: 0o700 }); From 7da16a8fba71d582975279da35a85377b77bdc0e Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sun, 23 Aug 2026 13:57:23 +0500 Subject: [PATCH 21/22] fix(kimi): use monotonic OAuth deadlines Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/provider/kimi/KimiOAuth.test.ts | 56 ++++++++++++++++++- apps/server/src/provider/kimi/KimiOAuth.ts | 24 +++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/kimi/KimiOAuth.test.ts b/apps/server/src/provider/kimi/KimiOAuth.test.ts index ab7efe718406..647c523d08ac 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.test.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.test.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ServerSettings, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -287,6 +288,59 @@ it.layer(NodeServices.layer)("signInWithKimi", (it) => { }), ); + it.effect("expires by monotonic time after the wall clock moves backward", () => + Effect.gen(function* () { + const testClock = yield* TestClock.testClockWith(Effect.succeed); + const wallClockMoved = yield* Deferred.make(); + const requests: Array = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const isDeviceAuthorization = request.url.includes("device_authorization"); + requests.push({ url: request.url, params: new URLSearchParams() }); + if (!isDeviceAuthorization) { + yield* testClock.setTime(-10_000); + yield* Deferred.succeed(wallClockMoved, undefined); + } + return HttpClientResponse.fromWeb( + request, + new Response( + // @effect-diagnostics-next-line preferSchemaOverJson:off - mock wire payloads are free-form test fixtures. + JSON.stringify( + isDeviceAuthorization + ? { ...DEVICE_AUTHORIZATION_BODY, expires_in: 2, interval: 1 } + : { error: "authorization_pending" }, + ), + { + status: isDeviceAuthorization ? 200 : 400, + headers: { "content-type": "application/json" }, + }, + ), + ); + }), + ), + ); + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + + yield* TestClock.adjust("1 second"); + yield* Deferred.await(wallClockMoved); + yield* TestClock.adjust("1 second"); + const completed = outcome.pollUnsafe(); + + expect(completed).toBeDefined(); + expect(yield* Fiber.join(outcome)).toBeInstanceOf(KimiAuthExpiredError); + expect(requests.filter((request) => request.url.includes("/api/oauth/token"))).toHaveLength( + 1, + ); + }), + ); + it.effect("keeps raw OAuth wire failures out of stable error context", () => Effect.gen(function* () { const rawDescription = "x".repeat(10_000); @@ -375,7 +429,7 @@ it.layer(NodeServices.layer)("writeKimiCredentials", (it) => { const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-concurrent-creds-" }); const temporaryPaths = yield* Ref.make>([]); - const recordingFileSystem: FileSystem.FileSystem["Service"] = { + const recordingFileSystem: FileSystem.FileSystem = { ...fs, writeFileString: (filePath) => Ref.update(temporaryPaths, (current) => [...current, filePath]), diff --git a/apps/server/src/provider/kimi/KimiOAuth.ts b/apps/server/src/provider/kimi/KimiOAuth.ts index 0f9b6caa179d..cd58e7ebe593 100644 --- a/apps/server/src/provider/kimi/KimiOAuth.ts +++ b/apps/server/src/provider/kimi/KimiOAuth.ts @@ -60,6 +60,7 @@ const CREDENTIALS_FILE_NAME = "kimi-code.json"; const DEFAULT_POLL_INTERVAL_SECONDS = 5; const DEFAULT_EXPIRES_IN_SECONDS = 600; const MAX_SIGN_IN_DURATION = Duration.minutes(15); +const MAX_SIGN_IN_DURATION_NANOS = Duration.toNanosUnsafe(MAX_SIGN_IN_DURATION); /** Where the Kimi CLI resolves its data root, honoring a per-instance homePath. */ export function resolveKimiCodeHome(homePath: string | null | undefined): string { @@ -321,15 +322,24 @@ export function signInWithKimi( }; const completion = Effect.gen(function* () { - const startedAtMs = yield* Clock.currentTimeMillis; - const deadlineMs = - startedAtMs + Math.min(expiresInSeconds * 1000, Duration.toMillis(MAX_SIGN_IN_DURATION)); + const startedAtNanos = yield* Clock.monotonicTimeNanos; + const expiresInNanos = Duration.toNanosUnsafe( + Duration.seconds(Math.max(0, expiresInSeconds)), + ); + const deadlineNanos = + startedAtNanos + + (expiresInNanos < MAX_SIGN_IN_DURATION_NANOS + ? expiresInNanos + : MAX_SIGN_IN_DURATION_NANOS); let intervalSeconds = Math.max(1, authorization.interval ?? DEFAULT_POLL_INTERVAL_SECONDS); - while ((yield* Clock.currentTimeMillis) < deadlineMs) { - const remainingMs = deadlineMs - (yield* Clock.currentTimeMillis); - yield* Effect.sleep(Duration.millis(Math.min(intervalSeconds * 1000, remainingMs))); - if ((yield* Clock.currentTimeMillis) >= deadlineMs) { + while ((yield* Clock.monotonicTimeNanos) < deadlineNanos) { + const remainingNanos = deadlineNanos - (yield* Clock.monotonicTimeNanos); + const intervalNanos = Duration.toNanosUnsafe(Duration.seconds(intervalSeconds)); + yield* Effect.sleep( + Duration.nanos(intervalNanos < remainingNanos ? intervalNanos : remainingNanos), + ); + if ((yield* Clock.monotonicTimeNanos) >= deadlineNanos) { break; } const poll = yield* pollToken(authorization.device_code); From c0140e28f5ca3437f31b5109db956f6ecb525b81 Mon Sep 17 00:00:00 2001 From: ItsJazii Date: Sun, 23 Aug 2026 14:28:25 +0500 Subject: [PATCH 22/22] fix(kimi): keep wrapper error details stable Three wrapper errors folded cause.message into detail, making the caller-visible message depend on the underlying error instead of stable structural context while cause was already preserved. Fixed phrases now, matching AetherDriver and OpenCode2Driver. Sweep confirmed no other Kimi file derives detail from an error message. Implemented by Claude via Cursor. --- apps/server/src/provider/Drivers/KimiDriver.ts | 2 +- apps/server/src/provider/Layers/KimiAdapter.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/KimiDriver.ts b/apps/server/src/provider/Drivers/KimiDriver.ts index 96a549cddfb5..f3fb7e935c54 100644 --- a/apps/server/src/provider/Drivers/KimiDriver.ts +++ b/apps/server/src/provider/Drivers/KimiDriver.ts @@ -237,7 +237,7 @@ export const KimiDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Kimi snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Kimi provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/KimiAdapter.ts b/apps/server/src/provider/Layers/KimiAdapter.ts index 176dd9307567..1d676ea533f6 100644 --- a/apps/server/src/provider/Layers/KimiAdapter.ts +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -605,7 +605,7 @@ export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapte new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: cause.message, + detail: "Failed to start the Kimi ACP session.", cause, }), ), @@ -1054,7 +1054,7 @@ export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapte new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: "Failed to read a turn attachment.", cause, }), ),