diff --git a/.gitignore b/.gitignore index 07793efe9b52..2c1f32b64897 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ __screenshots__/ squashfs-root/ .vercel .gstack/ +.worktrees/ dist-electron/ .electron-runtime/ .showcase/ diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..b3f38e563a6e 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -49,6 +49,14 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "kimi") { + return ( + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 637b7b5ee188..84fe1baba111 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -138,6 +138,7 @@ function ModelRow(props: { function ProviderHeader(props: { readonly driver: string | undefined; readonly label: string; + readonly badgeLabel: string | undefined; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; @@ -148,6 +149,11 @@ function ProviderHeader(props: { <> {props.label} + {props.badgeLabel ? ( + + {props.badgeLabel} + + ) : null} {props.collapsible ? ( <> @@ -512,6 +518,7 @@ type ThreadSettingsProviderCatalog = { readonly key: string; readonly driver: string | undefined; readonly label: string; + readonly badgeLabel: string | undefined; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; @@ -577,6 +584,7 @@ function ThreadSettingsProviderListHeader(props: { collapsed={props.provider.collapsed} driver={props.provider.driver} label={props.provider.label} + badgeLabel={props.provider.badgeLabel} modelCount={props.provider.modelCount} onToggle={onToggle} /> @@ -622,6 +630,7 @@ function useThreadSettingsCatalogItems( key: group.providerKey, driver, label: group.providerLabel, + badgeLabel: group.providerBadgeLabel, collapsible, collapsed, modelCount: visibleModels.length, diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 2e8fee98572a..f7ad1ab8aabe 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -16,6 +16,7 @@ function modelOption( providerKey: "codex", providerLabel: "Codex", providerDriver: "codex", + providerBadgeLabel: undefined, isDefault: false, isLegacy: false, capabilities: null, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..6e1b7490df4b 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -10,6 +10,35 @@ import { } from "./modelOptions"; describe("mobile model options", () => { + it("uses the Kimi product name when a snapshot has no display name", () => { + const config = { + providers: [ + { + instanceId: "kimi", + driver: "kimi", + badgeLabel: "Early Access", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "kimi-dynamic-model", + name: "Kimi Dynamic Model", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect(groupByProvider(buildModelOptions(config, null))[0]).toMatchObject({ + providerLabel: "Kimi", + providerBadgeLabel: "Early Access", + models: [{ providerBadgeLabel: "Early Access" }], + }); + }); + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..44d9cad327a4 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -15,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly providerBadgeLabel: string | undefined; readonly isDefault: boolean; readonly isLegacy: boolean; readonly capabilities: ModelCapabilities | null; @@ -24,6 +25,7 @@ export type ModelOption = { export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; + readonly providerBadgeLabel: string | undefined; readonly models: ReadonlyArray; }; @@ -35,6 +37,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "kimi") return "Kimi"; return provider.instanceId; } @@ -125,6 +128,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + providerBadgeLabel: provider.badgeLabel, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, @@ -156,6 +160,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + providerBadgeLabel: undefined, isDefault: false, isLegacy: false, capabilities: null, @@ -168,7 +173,14 @@ export function buildModelOptions( } export function groupByProvider(options: ReadonlyArray): ReadonlyArray { - const groups = new Map(); + const groups = new Map< + string, + { + providerLabel: string; + providerBadgeLabel: string | undefined; + models: ModelOption[]; + } + >(); for (const option of options) { const existing = groups.get(option.providerKey); if (existing) { @@ -176,6 +188,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, + providerBadgeLabel: option.providerBadgeLabel, models: [option], }); } @@ -184,6 +197,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr return [...groups.entries()].map(([providerKey, group]) => ({ providerKey, providerLabel: group.providerLabel, + providerBadgeLabel: group.providerBadgeLabel, models: group.models, })); } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..7c46eec34892 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -17,6 +17,13 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; +const emitAvailableCommandUpdates = process.env.T3_ACP_EMIT_AVAILABLE_COMMAND_UPDATES === "1"; +const emitAvailableCommandClearUpdates = + process.env.T3_ACP_EMIT_AVAILABLE_COMMAND_CLEAR_UPDATES === "1"; +const emitAvailableCommandsDuringCreate = + process.env.T3_ACP_EMIT_AVAILABLE_COMMANDS_DURING_CREATE === "1"; +const exitAfterPrompt = process.env.T3_ACP_EXIT_AFTER_PROMPT === "1"; +const exitBeforePromptResponse = process.env.T3_ACP_EXIT_BEFORE_PROMPT_RESPONSE === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; @@ -24,9 +31,13 @@ const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATE const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; +const omitCodeMode = process.env.T3_ACP_OMIT_CODE_MODE === "1"; +const omitModelConfig = process.env.T3_ACP_OMIT_MODEL_CONFIG === "1"; const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; +const advertiseResume = process.env.T3_ACP_ADVERTISE_RESUME === "1"; +const failResumeSession = process.env.T3_ACP_FAIL_RESUME_SESSION === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; @@ -37,6 +48,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const setConfigOptionDelayMs = Number(process.env.T3_ACP_SET_CONFIG_OPTION_DELAY_MS ?? "0"); const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -207,21 +219,23 @@ function configOptions(): ReadonlyArray { } } - return [ - { - id: "model", - name: "Model", - category: "model", - type: "select" as const, - currentValue: currentModelId, - options: [ - { value: "default", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, - { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, - ], - }, - ]; + return omitModelConfig + ? [] + : [ + { + id: "model", + name: "Model", + category: "model", + type: "select" as const, + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + ], + }, + ]; } function modelConfigOptionsFor(modelId: string): ReadonlyArray { @@ -269,7 +283,7 @@ const availableModes: ReadonlyArray = [ name: "Code", description: "Write and modify code with full tool access", }, -]; +].filter((mode) => !omitCodeMode || mode.id !== "code"); function modeState(): AcpSchema.SessionModeState { return { @@ -302,7 +316,10 @@ const program = Effect.gen(function* () { request.clientCapabilities?._meta?.parameterizedModelPicker === true; return { protocolVersion: 1, - agentCapabilities: { loadSession: true }, + agentCapabilities: { + loadSession: true, + ...(advertiseResume ? { sessionCapabilities: { resume: {} } } : {}), + }, }; }), ); @@ -310,11 +327,24 @@ const program = Effect.gen(function* () { yield* agent.handleAuthenticate(() => Effect.succeed({})); yield* agent.handleCreateSession(() => - Effect.succeed({ - sessionId, - modes: modeState(), - models: modelState(), - configOptions: configOptions(), + Effect.gen(function* () { + if (emitAvailableCommandsDuringCreate) { + yield* agent.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "skill:startup", description: "Available during session creation" }, + ], + }, + }); + } + return { + sessionId, + modes: modeState(), + models: modelState(), + configOptions: configOptions(), + }; }), ); @@ -380,6 +410,12 @@ const program = Effect.gen(function* () { }), ); + if (failResumeSession) { + yield* agent.handleResumeSession(() => + Effect.fail(AcpError.AcpRequestError.invalidParams("Mock resume session failure")), + ); + } + yield* agent.handleSetSessionModel((request) => Effect.gen(function* () { if (!grokAcpModels.some((model) => model.modelId === request.modelId)) { @@ -398,6 +434,9 @@ const program = Effect.gen(function* () { yield* agent.handleSetSessionConfigOption((request) => Effect.gen(function* () { + if (Number.isFinite(setConfigOptionDelayMs) && setConfigOptionDelayMs > 0) { + yield* Effect.sleep(`${setConfigOptionDelayMs} millis`); + } if (exitOnSetConfigOption) { return yield* Effect.sync(() => { process.exit(7); @@ -754,6 +793,59 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitAvailableCommandUpdates) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "skill:ship", + description: "Prepare the current change for delivery", + }, + ], + }, + }); + return { stopReason: "end_turn" }; + } + + if (emitAvailableCommandClearUpdates) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [], + }, + }); + return { stopReason: "end_turn" }; + } + if (emitAskQuestion) { yield* agent.client.extRequest("cursor/ask_question", { toolCallId: "ask-question-tool-call-1", @@ -873,6 +965,22 @@ const program = Effect.gen(function* () { }, }); + if (exitAfterPrompt) { + yield* Effect.sleep("1 millis").pipe( + Effect.andThen( + Effect.sync((): void => { + process.exit(9); + }), + ), + Effect.forkDetach, + ); + } + if (exitBeforePromptResponse) { + yield* Effect.sync((): void => { + process.exit(9); + }); + } + return { stopReason: "end_turn" }; }), ); diff --git a/apps/server/src/provider/Drivers/KimiDriver.ts b/apps/server/src/provider/Drivers/KimiDriver.ts new file mode 100644 index 000000000000..1b8974fcd321 --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiDriver.ts @@ -0,0 +1,160 @@ +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 { type ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { makeKimiContinuationGroupKey, makeKimiEnvironment } from "./KimiHome.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); +const DRIVER_KIND = ProviderDriverKind.make("kimi"); +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "@moonshot-ai/kimi-code", + homebrewFormula: null, + nativeUpdate: 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 childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const effectiveConfig = { ...config, enabled } satisfies KimiSettings; + const processEnvironment = yield* makeKimiEnvironment( + effectiveConfig, + mergeProviderInstanceEnvironment(environment), + ); + const continuationIdentity = { + driverKind: DRIVER_KIND, + continuationKey: yield* makeKimiContinuationGroupKey(effectiveConfig, processEnvironment), + }; + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnvironment, + }); + const adapter = yield* makeKimiAdapter(effectiveConfig, { + environment: processEnvironment, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeKimiTextGeneration(effectiveConfig, processEnvironment); + const checkProvider = checkKimiProviderStatus(effectiveConfig, processEnvironment).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKimiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichKimiSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the Kimi provider snapshot.", + cause, + }), + ), + ); + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/KimiHome.test.ts b/apps/server/src/provider/Drivers/KimiHome.test.ts new file mode 100644 index 000000000000..6dec3b2d52ff --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiHome.test.ts @@ -0,0 +1,73 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + makeKimiContinuationGroupKey, + makeKimiEnvironment, + resolveKimiHomePath, +} from "./KimiHome.ts"; + +it.layer(NodeServices.layer)("KimiHome", (it) => { + it.effect("resolves the default Kimi home without forcing it into the child environment", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnvironment = { PATH: "bin" }; + const resolvedHome = path.resolve(NodeOS.homedir(), ".kimi-code"); + + expect(yield* resolveKimiHomePath({ homePath: "" })).toBe(resolvedHome); + expect(yield* makeKimiEnvironment({ homePath: "" }, baseEnvironment)).toEqual( + baseEnvironment, + ); + expect(yield* makeKimiContinuationGroupKey({ homePath: "" })).toBe( + `kimi:home:${resolvedHome}`, + ); + }), + ); + + it.effect("expands an explicit Kimi home for the process and continuation identity", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnvironment = { PATH: "bin" }; + const resolvedHome = path.resolve(NodeOS.homedir(), ".kimi-work"); + + expect(yield* resolveKimiHomePath({ homePath: "~/.kimi-work" })).toBe(resolvedHome); + expect( + (yield* makeKimiEnvironment({ homePath: "~/.kimi-work" }, baseEnvironment)).KIMI_CODE_HOME, + ).toBe(resolvedHome); + expect(yield* makeKimiContinuationGroupKey({ homePath: "~/.kimi-work" })).toBe( + `kimi:home:${resolvedHome}`, + ); + }), + ); + + it.effect("uses the child environment for Kimi's implicit home and continuation identity", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = { HOME: path.resolve("/isolated-user") }; + const resolvedHome = path.resolve(environment.HOME, ".kimi-code"); + + expect(yield* resolveKimiHomePath({ homePath: "" }, environment)).toBe(resolvedHome); + expect(yield* makeKimiContinuationGroupKey({ homePath: "" }, environment)).toBe( + `kimi:home:${resolvedHome}`, + ); + }), + ); + + it.effect("honors an inherited KIMI_CODE_HOME when no explicit home is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = { + HOME: path.resolve("/isolated-user"), + KIMI_CODE_HOME: "~/.kimi-from-env", + }; + const resolvedHome = path.resolve(environment.HOME, ".kimi-from-env"); + + expect(yield* resolveKimiHomePath({ homePath: "" }, environment)).toBe(resolvedHome); + expect(yield* makeKimiEnvironment({ homePath: "" }, environment)).toBe(environment); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/KimiHome.ts b/apps/server/src/provider/Drivers/KimiHome.ts new file mode 100644 index 000000000000..d164a1a700f2 --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiHome.ts @@ -0,0 +1,52 @@ +import * as NodeOS from "node:os"; + +import type { KimiSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +function environmentHome(environment: NodeJS.ProcessEnv | undefined): string { + return environment?.HOME?.trim() || environment?.USERPROFILE?.trim() || NodeOS.homedir(); +} + +function expandAgainstHome(path: Path.Path, value: string, home: string): string { + if (value === "~") return home; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(home, value.slice(2)); + } + return value; +} + +export const resolveKimiHomePath = Effect.fn("resolveKimiHomePath")(function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const path = yield* Path.Path; + const home = environmentHome(environment); + const configuredHome = config.homePath.trim(); + const inheritedHome = environment?.KIMI_CODE_HOME?.trim() ?? ""; + const homePath = configuredHome || inheritedHome; + return homePath + ? path.resolve(expandAgainstHome(path, homePath, home)) + : path.resolve(home, ".kimi-code"); +}); + +export const makeKimiEnvironment = Effect.fn("makeKimiEnvironment")(function* ( + config: Pick, + baseEnv?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const environment = baseEnv ?? process.env; + if (config.homePath.trim().length === 0) { + return environment; + } + return { + ...environment, + KIMI_CODE_HOME: yield* resolveKimiHomePath(config, environment), + }; +}); + +export const makeKimiContinuationGroupKey = Effect.fn("makeKimiContinuationGroupKey")(function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + return `kimi:home:${yield* resolveKimiHomePath(config, environment)}`; +}); diff --git a/apps/server/src/provider/Drivers/KimiSkills.test.ts b/apps/server/src/provider/Drivers/KimiSkills.test.ts new file mode 100644 index 000000000000..514dda925a6a --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiSkills.test.ts @@ -0,0 +1,142 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverKimiSkills } from "./KimiSkills.ts"; + +const writeSkill = Effect.fn(function* (root: string, directory: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDirectory = path.join(root, directory); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); +}); + +it.layer(NodeServices.layer)("discoverKimiSkills", (it) => { + it.effect("sorts skills and lets project definitions replace user definitions", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-kimi-skills-", + }); + const kimiHome = path.join(tempDirectory, "kimi-home"); + const osHome = path.join(tempDirectory, "os-home"); + const workspace = path.join(tempDirectory, "workspace"); + + yield* writeSkill( + path.join(kimiHome, "skills"), + "review", + ["---", "name: review", "description: Review from Kimi home.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(osHome, ".agents", "skills"), + "shared", + ["---", "name: shared", "description: Shared user skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".kimi-code", "skills"), + "review", + ["---", "name: review", "description: Project review skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "project", + ["---", "name: project", "description: Project agent skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "malformed", + ["---", "name: [unclosed", "---"].join("\n"), + ); + + const skills = yield* discoverKimiSkills({ homePath: kimiHome }, workspace, { HOME: osHome }); + + assert.deepEqual(skills, [ + { + name: "project", + description: "Project agent skill.", + path: path.join(workspace, ".agents", "skills", "project", "SKILL.md"), + enabled: true, + scope: "project", + }, + { + name: "review", + description: "Project review skill.", + path: path.join(workspace, ".kimi-code", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + }, + { + name: "shared", + description: "Shared user skill.", + path: path.join(osHome, ".agents", "skills", "shared", "SKILL.md"), + enabled: true, + scope: "user", + }, + ]); + }), + ); + + it.effect("skips missing roots and entries without a readable skill file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-kimi-skills-", + }); + const kimiHome = path.join(tempDirectory, "kimi-home"); + const workspace = path.join(tempDirectory, "workspace"); + yield* fileSystem.makeDirectory(path.join(kimiHome, "skills", "empty"), { recursive: true }); + + const skills = yield* discoverKimiSkills({ homePath: kimiHome }, workspace, { + HOME: path.join(tempDirectory, "os-home"), + }); + + assert.deepEqual(skills, []); + }), + ); + + it.effect("uses USERPROFILE for shared Windows agent skills when HOME is absent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-kimi-skills-userprofile-", + }); + const userProfile = path.join(tempDirectory, "profile"); + yield* writeSkill( + path.join(userProfile, ".agents", "skills"), + "windows-shared", + ["---", "name: windows-shared", "description: Windows shared skill.", "---"].join("\n"), + ); + + const skills = yield* discoverKimiSkills( + { homePath: path.join(tempDirectory, "kimi-home") }, + undefined, + { USERPROFILE: userProfile }, + ); + + assert.equal(skills[0]?.name, "windows-shared"); + }), + ); + + it.effect("does not follow a discovered skill link outside its configured root", () => { + // Windows can prohibit test symlink creation. This filesystem double models + // its relevant observable behavior: a listed child resolves outside its root. + const fileSystem = FileSystem.makeNoop({ + realPath: (value) => + Effect.succeed(value.endsWith("SKILL.md") ? "C:\\outside\\SKILL.md" : "C:\\root"), + readDirectory: () => Effect.succeed(["escaped"]), + readFileString: () => Effect.die("outside skill content must never be read"), + }); + return discoverKimiSkills({ homePath: "C:\\kimi-home" }, "C:\\workspace", { + HOME: "C:\\os-home", + }).pipe( + Effect.tap((skills) => Effect.sync(() => assert.deepEqual(skills, []))), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/KimiSkills.ts b/apps/server/src/provider/Drivers/KimiSkills.ts new file mode 100644 index 000000000000..30cbdcb1010f --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiSkills.ts @@ -0,0 +1,128 @@ +import * as NodeOS from "node:os"; + +import type { KimiSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +import { resolveKimiHomePath } from "./KimiHome.ts"; + +type KimiSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +type SkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "missing" }; + } + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed" }; + } + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +function isPathWithinRoot(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** + * Enumerates skills Kimi Code can discover from user and project roots. Project + * skills are registered last so their names replace broader user definitions. + */ +export const discoverKimiSkills = Effect.fn("discoverKimiSkills")(function* ( + config: Pick, + cwd?: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const kimiHome = yield* resolveKimiHomePath(config, environment); + const osHome = environment?.HOME?.trim() || environment?.USERPROFILE?.trim() || NodeOS.homedir(); + const roots: ReadonlyArray<{ readonly directory: string; readonly scope: KimiSkillScope }> = [ + { directory: path.join(kimiHome, "skills"), scope: "user" }, + { directory: path.join(osHome, ".agents", "skills"), scope: "user" }, + ...(cwd + ? [ + { directory: path.join(cwd, ".kimi-code", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + ] + : []), + ]; + + const skillsByName = new Map(); + for (const root of roots) { + const realRoot = yield* fileSystem + .realPath(root.directory) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!realRoot) { + continue; + } + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const realSkillPath = yield* fileSystem + .realPath(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!realSkillPath || !isPathWithinRoot(path, realRoot, realSkillPath)) { + continue; + } + const contents = yield* fileSystem + .readFileString(realSkillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + if (frontmatter.kind === "malformed") { + continue; + } + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Drivers/KimiVersion.ts b/apps/server/src/provider/Drivers/KimiVersion.ts new file mode 100644 index 000000000000..8fa8998fba43 --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiVersion.ts @@ -0,0 +1,44 @@ +import type { KimiSettings } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +export const MINIMUM_KIMI_THINKING_LEVELS_VERSION = "0.29.0"; + +export function parseKimiCliVersion(output: string): string | null { + const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)\b/); + return match?.[1] ?? null; +} + +export function getKimiCliCompatibilityIssue(version: string | null): string | null { + if (version === null) { + return `Unable to determine Kimi version from \`kimi --version\` output. T3 Code requires Kimi v${MINIMUM_KIMI_THINKING_LEVELS_VERSION} or newer.`; + } + if (compareSemverVersions(version, MINIMUM_KIMI_THINKING_LEVELS_VERSION) < 0) { + return `Kimi CLI v${version} is too old. Upgrade to v${MINIMUM_KIMI_THINKING_LEVELS_VERSION} or newer to use selectable thinking levels.`; + } + return null; +} + +export const runKimiVersionCommand = ( + settings: Pick, + environment?: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "kimi"; + const spawnCommand = yield* resolveSpawnCommand( + command, + ["--version"], + environment ? { env: environment } : {}, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : {}), + shell: spawnCommand.shell, + }), + ); + }); diff --git a/apps/server/src/provider/Layers/KimiAdapter.test.ts b/apps/server/src/provider/Layers/KimiAdapter.test.ts new file mode 100644 index 000000000000..3aeeec014834 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.test.ts @@ -0,0 +1,486 @@ +// @effect-diagnostics nodeBuiltinImport:off - executable ACP fixture setup uses Node filesystem boundaries. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + KimiSettings, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { makeKimiAdapter } from "./KimiAdapter.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +const makeKimiWrapper = Effect.fn("makeKimiWrapper")(function* ( + extraEnv?: Record, + version = "0.29.0", +) { + const windows = (yield* HostProcessPlatform) === "win32"; + return yield* Effect.promise(async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-mock-")); + const wrapperPath = NodePath.join(directory, windows ? "kimi.cmd" : "fake-kimi.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => + windows + ? `set "${key}=${value.replaceAll('"', '\\"')}"` + : `export ${key}=${encodeJsonString(value)}`, + ) + .join("\n"); + const script = windows + ? `@echo off\r\nif "%~1"=="--version" (\r\n echo kimi ${version}\r\n exit /b 0\r\n)\r\n${envExports}\r\n${encodeJsonString(process.execPath)} ${encodeJsonString(mockAgentPath)} %*\r\n` + : `#!/bin/sh\nif [ "$1" = "--version" ]; then\n printf 'kimi ${version}\\n'\n exit 0\nfi\n${envExports}\nexec ${encodeJsonString(process.execPath)} ${encodeJsonString(mockAgentPath)} "$@"\n`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + if (!windows) await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; + }); +}); + +async function readRequests(requestLogPath: string) { + const raw = await NodeFSP.readFile(requestLogPath, "utf8"); + return raw + .split("\n") + .filter((line) => line.trim()) + .map( + (line) => + JSON.parse(line) as { + readonly method?: string; + readonly params?: { + readonly configId?: string; + readonly value?: unknown; + readonly mcpServers?: ReadonlyArray; + }; + }, + ); +} + +const readRequestMethods = (requestLogPath: string) => + readRequests(requestLogPath).then((entries) => entries.map((entry) => entry.method)); + +const kimiAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-kimi-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = ( + binaryPath: string, + instanceId = ProviderInstanceId.make("kimi"), + nativeEventLogger?: EventNdjsonLogger, +) => + makeKimiAdapter(decodeKimiSettings({ binaryPath }), { + instanceId, + ...(nativeEventLogger ? { nativeEventLogger } : {}), + }).pipe(Effect.orDie); + +it.layer(kimiAdapterTestLayer)("KimiAdapter", (it) => { + it.effect("rejects an incompatible Kimi CLI before starting ACP", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter(yield* makeKimiWrapper(undefined, "0.28.1")); + const threadId = ThreadId.make("kimi-old-version"); + const result = yield* Effect.result( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }), + ); + + if (Result.isSuccess(result)) { + yield* adapter.stopSession(threadId); + assert.fail("expected an incompatible Kimi version to be rejected"); + } + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + assert.include(result.failure.message, "0.29.0"); + }), + ); + + it.effect("starts a Kimi ACP session and emits canonical prompt lifecycle events", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-mode-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const threadId = ThreadId.make("kimi-lifecycle"); + const events: ProviderRuntimeEvent[] = []; + const completed = yield* Deferred.make(); + const assistantCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" ? Deferred.succeed(completed, undefined) : Effect.void, + ), + Effect.andThen( + event.type === "item.completed" && event.payload.itemType === "assistant_message" + ? Deferred.succeed(assistantCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("kimi"), model: "default" }, + }); + assert.equal(session.provider, "kimi"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.isTrue( + (yield* Effect.promise(() => readRequests(requestLogPath))).some( + (entry) => + entry.method === "session/set_config_option" && + entry.params?.configId === "mode" && + entry.params.value === "code", + ), + ); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "Inspect this repository", + attachments: [], + }); + assert.equal(turn.threadId, threadId); + assert.include( + events.map((event) => event.type), + "item.completed", + ); + yield* Deferred.await(completed); + yield* Deferred.await(assistantCompleted); + yield* Fiber.interrupt(eventsFiber); + + for (const type of [ + "item.started", + "content.delta", + "item.completed", + "turn.plan.updated", + "turn.completed", + ] as const) { + assert.include( + events.map((event) => event.type), + type, + ); + } + assert.isTrue(events.every((event) => event.provider === ProviderDriverKind.make("kimi"))); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not start an ACP prompt when interruption wins during turn setup", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-interrupt-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_SET_CONFIG_OPTION_DELAY_MS: "100", + }), + ); + const threadId = ThreadId.make("kimi-interrupt-setup"); + const started = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.threadId !== threadId + ? Effect.void + : event.type === "turn.started" && event.turnId + ? Deferred.succeed(started, event.turnId).pipe(Effect.asVoid) + : event.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const turnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Do not prompt after cancellation", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("kimi"), + model: "composer-2", + }, + }) + .pipe(Effect.forkChild); + const turnId = yield* Deferred.await(started); + yield* adapter.interruptTurn(threadId, turnId); + yield* Fiber.join(turnFiber); + yield* Deferred.await(completed); + + assert.notInclude( + yield* Effect.promise(() => readRequestMethods(requestLogPath)), + "session/prompt", + ); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("leaves plan mode when a specialized full-access mode is unavailable", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-mode-fallback-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ + T3_ACP_OMIT_CODE_MODE: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const threadId = ThreadId.make("kimi-mode-fallback"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Plan first", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.sendTurn({ threadId, input: "Now implement", attachments: [] }); + + const modeValues = (yield* Effect.promise(() => readRequests(requestLogPath))) + .filter( + (entry) => + entry.method === "session/set_config_option" && entry.params?.configId === "mode", + ) + .map((entry) => entry.params?.value); + assert.deepStrictEqual(modeValues, ["architect", "ask"]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes the ACP session and keeps adapter instances isolated", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-resume-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const first = yield* makeTestAdapter( + yield* makeKimiWrapper({ + T3_ACP_ADVERTISE_RESUME: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ProviderInstanceId.make("kimi-work"), + ); + const second = yield* makeTestAdapter( + yield* makeKimiWrapper(), + ProviderInstanceId.make("kimi-personal"), + ); + const resumedThread = ThreadId.make("kimi-resumed"); + const otherThread = ThreadId.make("kimi-other-instance"); + + const session = yield* first.startSession({ + threadId: resumedThread, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "kimi-session-1" }, + modelSelection: { instanceId: ProviderInstanceId.make("kimi-work"), model: "default" }, + }); + yield* second.startSession({ + threadId: otherThread, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.equal(session.providerInstanceId, ProviderInstanceId.make("kimi-work")); + assert.isTrue(yield* first.hasSession(resumedThread)); + assert.isFalse(yield* second.hasSession(resumedThread)); + assert.isTrue(yield* second.hasSession(otherThread)); + const requestMethods = yield* Effect.promise(() => readRequestMethods(requestLogPath)); + assert.include(requestMethods, "session/resume"); + + yield* first.stopAll(); + assert.isFalse(yield* first.hasSession(resumedThread)); + assert.isTrue(yield* second.hasSession(otherThread)); + yield* second.stopAll(); + }), + ); + + it.effect("preserves the T3 MCP credential when replacing a session", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-mcp-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const threadId = ThreadId.make("kimi-mcp-replacement"); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-1"), + threadId, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("kimi"), + endpoint: "http://127.0.0.1:4567/mcp", + authorizationHeader: "Bearer test-token", + }); + + const input = { + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required" as const, + }; + yield* adapter.startSession(input); + yield* adapter.startSession(input); + + const newSessionRequests = (yield* Effect.promise(() => readRequests(requestLogPath))).filter( + (entry) => entry.method === "session/new", + ); + assert.equal(newSessionRequests.length, 2); + assert.isTrue(newSessionRequests.every((entry) => entry.params?.mcpServers?.length === 1)); + assert.isDefined(McpProviderSession.readMcpProviderSession(threadId)); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("removes a session when the Kimi ACP process exits", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ T3_ACP_EXIT_BEFORE_PROMPT_RESPONSE: "1" }), + ); + const threadId = ThreadId.make("kimi-process-exit"); + const exited = yield* Deferred.make(); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.threadId === threadId && event.type === "session.exited" + ? Deferred.succeed(exited, event).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter + .sendTurn({ threadId, input: "exit before responding", attachments: [] }) + .pipe(Effect.result); + const exitEvent = yield* Deferred.await(exited); + + assert.deepInclude(exitEvent.payload, { exitKind: "error" }); + const relevantTypes = events + .filter((event) => event.threadId === threadId) + .map((event) => event.type); + assert.isBelow( + relevantTypes.indexOf("turn.completed"), + relevantTypes.indexOf("session.exited"), + ); + assert.equal(relevantTypes.filter((type) => type === "turn.completed").length, 1); + assert.isFalse(yield* adapter.hasSession(threadId)); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("rejects an empty turn before changing session configuration", () => + Effect.gen(function* () { + const directory = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-empty-turn-")), + ); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const threadId = ThreadId.make("kimi-empty-turn"); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const methodsBefore = yield* Effect.promise(() => readRequestMethods(requestLogPath)); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)), + ).pipe(Effect.forkChild); + + const error = yield* adapter + .sendTurn({ threadId, input: " ", attachments: [] }) + .pipe(Effect.flip); + const methodsAfter = yield* Effect.promise(() => readRequestMethods(requestLogPath)); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + assert.deepEqual(methodsAfter, methodsBefore); + assert.notInclude( + events.map((event) => event.type), + "turn.started", + ); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("writes native ACP events through the configured logger", () => + Effect.gen(function* () { + const records: unknown[] = []; + const logger: EventNdjsonLogger = { + filePath: "memory://kimi-native-events", + write: (event) => Effect.sync(() => records.push(event)), + close: () => Effect.void, + }; + const adapter = yield* makeTestAdapter( + yield* makeKimiWrapper(), + ProviderInstanceId.make("kimi"), + logger, + ); + const threadId = ThreadId.make("kimi-native-logging"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("kimi"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + assert.isAbove(records.length, 0); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiAdapter.ts b/apps/server/src/provider/Layers/KimiAdapter.ts new file mode 100644 index 000000000000..ed4b6af7223b --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -0,0 +1,1079 @@ +/** + * KimiAdapterLive - Kimi Code (`kimi acp`) sessions via the standard ACP runtime. + * + * @module KimiAdapterLive + */ +import { + ApprovalRequestId, + type KimiSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + 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 Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +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 { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { applyKimiAcpModelSelection, makeKimiAcpRuntime } from "../acp/KimiAcpSupport.ts"; +import { parsePermissionRequest, type AcpSessionModeState } from "../acp/AcpRuntimeModel.ts"; +import { + extractKimiPermissionQuestions, + resolveKimiQuestionPermissionOption, +} from "../acp/KimiUserInput.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + getKimiCliCompatibilityIssue, + parseKimiCliVersion, + runKimiVersionCommand, +} from "../Drivers/KimiVersion.ts"; +import type { KimiAdapterShape } from "../Services/KimiAdapter.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("kimi"); +const KIMI_RESUME_VERSION = 1 as const; +const KimiResumeCursor = Schema.Struct({ + schemaVersion: Schema.Literal(KIMI_RESUME_VERSION), + sessionId: Schema.String, +}); +const isKimiResumeCursor = Schema.is(KimiResumeCursor); + +export interface KimiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly instanceId?: ProviderInstanceId; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly request: EffectAcpSchema.RequestPermissionRequest; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface ThreadLockEntry { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +} + +interface KimiSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + session: ProviderSession; + preparingTurnId: TurnId | undefined; + activeTurnId: TurnId | undefined; + promptsInFlight: number; + readonly interruptedTurnIds: Set; + readonly turns: Array<{ id: TurnId; items: Array }>; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + notificationFiber: Fiber.Fiber | undefined; + readonly stoppedSignal: Deferred.Deferred; + readonly turnCompletionLock: Semaphore.Semaphore; + readonly supportsImages: boolean; + stopped: boolean; +} + +function parseKimiResume(raw: unknown): { readonly sessionId: string } | undefined { + if (!isKimiResumeCursor(raw) || !raw.sessionId.trim()) { + return undefined; + } + return { sessionId: raw.sessionId.trim() }; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const option = request.options.find( + (candidate) => candidate.kind === "allow_always" || candidate.kind === "allow_once", + ); + return option?.optionId.trim() || undefined; +} + +function permissionOptionIdForDecision( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : decision === "decline" + ? "reject_once" + : undefined; + if (!kind) return undefined; + return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; +} + +function findKimiMode( + modeState: AcpSessionModeState | undefined, + aliases: ReadonlyArray, +): string | undefined { + if (!modeState) return undefined; + for (const alias of aliases) { + const normalized = alias.toLowerCase(); + const mode = modeState.availableModes.find( + (entry) => entry.id.toLowerCase() === normalized || entry.name.toLowerCase() === normalized, + ); + if (mode) return mode.id; + } + return undefined; +} + +function requestedKimiModeId(input: { + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly interactionMode: "default" | "plan" | undefined; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + if (input.interactionMode === "plan") { + return findKimiMode(input.modeState, ["plan", "architect"]); + } + if (input.runtimeMode === "auto") { + return ( + findKimiMode(input.modeState, ["auto"]) ?? findKimiMode(input.modeState, ["default", "ask"]) + ); + } + if (input.runtimeMode === "full-access") { + return ( + findKimiMode(input.modeState, ["yolo", "code"]) ?? + findKimiMode(input.modeState, ["default", "ask"]) + ); + } + return findKimiMode(input.modeState, ["default", "ask"]); +} + +function initializedPromptSupportsImages( + initializeResult: EffectAcpSchema.InitializeResponse, +): boolean { + return initializeResult.agentCapabilities?.promptCapabilities?.image === true; +} + +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* ServerConfig; + const crypto = yield* Crypto.Crypto; + const adapterScope = yield* Scope.Scope; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const sessions = new Map(); + const threadLocks = yield* SynchronizedRef.make(new Map()); + const runtimeEvents = yield* PubSub.unbounded(); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const ensureSupportedKimiVersion = (threadId: ThreadId) => + Effect.gen(function* () { + const probeResult = yield* runKimiVersionCommand(kimiSettings, options?.environment).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.timeoutOption(Duration.seconds(4)), + Effect.result, + ); + if (Result.isFailure(probeResult)) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Failed to verify the Kimi CLI version before starting ACP.", + cause: probeResult.failure, + }); + } + if (Option.isNone(probeResult.success)) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "Kimi CLI version check timed out after 4 seconds.", + }); + } + const probe = probeResult.success.value; + if (probe.code !== 0) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `Kimi CLI version check exited with code ${probe.code}.`, + }); + } + const version = parseKimiCliVersion(`${probe.stdout}\n${probe.stderr}`); + const compatibilityIssue = getKimiCliCompatibilityIssue(version); + if (compatibilityIssue !== null) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: compatibilityIssue, + }); + } + }); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Kimi runtime identifier.", + cause, + }), + ), + ); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Kimi ACP callback.", + cause, + }), + ), + ); + const nextEventStamp = () => + Effect.all({ + eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), + createdAt: nowIso, + }); + + const publish = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEvents, event).pipe(Effect.asVoid); + const completeTurn = (input: { + readonly context: KimiSessionContext; + readonly turnId: TurnId; + readonly state: "cancelled" | "completed" | "failed"; + readonly stopReason: string | null; + readonly finalizeSession: boolean; + readonly model?: string; + }) => + input.context.turnCompletionLock.withPermit( + Effect.gen(function* () { + if (input.context.activeTurnId !== input.turnId || input.context.stopped) { + return false; + } + const updatedAt = yield* nowIso; + if (input.context.activeTurnId !== input.turnId || input.context.stopped) { + return false; + } + input.context.activeTurnId = undefined; + if (input.finalizeSession) { + input.context.session = { + ...input.context.session, + status: "ready", + activeTurnId: undefined, + updatedAt, + ...(input.model ? { model: input.model } : {}), + }; + } + yield* publish({ + type: "turn.completed", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.context.threadId, + turnId: input.turnId, + payload: { state: input.state, stopReason: input.stopReason }, + }); + return true; + }), + ); + const getThreadLock = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocks, (current) => { + const existing = current.get(threadId); + if (existing) { + return Effect.succeed([ + existing.semaphore, + new Map(current).set(threadId, { ...existing, users: existing.users + 1 }), + ] as const); + } + return Semaphore.make(1).pipe( + Effect.map( + (semaphore) => + [semaphore, new Map(current).set(threadId, { semaphore, users: 1 })] as const, + ), + ); + }); + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const lock = yield* getThreadLock(threadId); + return yield* restore(lock.withPermit(effect)).pipe( + Effect.ensuring( + SynchronizedRef.update(threadLocks, (current) => { + const entry = current.get(threadId); + if (entry?.semaphore !== lock) return current; + const next = new Map(current); + if (entry.users === 1) next.delete(threadId); + else next.set(threadId, { ...entry, users: entry.users - 1 }); + return next; + }), + ), + ); + }), + ); + const requireSession = (threadId: ThreadId) => { + const context = sessions.get(threadId); + return context && !context.stopped + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + const settleApprovals = (pending: ReadonlyMap) => + Effect.forEach( + pending.values(), + (approval) => Deferred.succeed(approval.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); + const settleUserInputs = (pending: ReadonlyMap) => + Effect.forEach( + pending.values(), + (input) => Deferred.succeed(input.answers, {}).pipe(Effect.ignore), + { discard: true }, + ); + const applyKimiMode = (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly interactionMode: "default" | "plan" | undefined; + readonly threadId: ThreadId; + }) => + Effect.gen(function* () { + const modeId = requestedKimiModeId({ + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + modeState: yield* input.runtime.getModeState, + }); + if (modeId) yield* input.runtime.setMode(modeId); + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + ), + ); + + const stopSessionInternal = ( + context: KimiSessionContext, + options?: { + readonly clearMcp?: boolean; + readonly exitKind?: "graceful" | "error"; + readonly reason?: string; + }, + ) => + Effect.gen(function* () { + if (context.stopped) return; + context.stopped = true; + context.preparingTurnId = undefined; + yield* Deferred.succeed(context.stoppedSignal, undefined); + yield* context.turnCompletionLock.withPermit( + Effect.gen(function* () { + const activeTurnId = context.activeTurnId; + if (activeTurnId === undefined) return; + context.activeTurnId = undefined; + yield* publish({ + type: "turn.completed", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: context.threadId, + turnId: activeTurnId, + payload: { + state: options?.exitKind === "error" ? "failed" : "cancelled", + stopReason: null, + }, + }); + }), + ); + yield* settleApprovals(context.pendingApprovals); + yield* settleUserInputs(context.pendingUserInputs); + if (context.notificationFiber) yield* Fiber.interrupt(context.notificationFiber); + yield* Effect.ignore(context.acp.cancel); + yield* Effect.ignore(Scope.close(context.scope, Exit.void)); + sessions.delete(context.threadId); + if (options?.clearMcp !== false) + McpProviderSession.clearMcpProviderSession(context.threadId); + yield* publish({ + type: "session.exited", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: context.threadId, + payload: { + exitKind: options?.exitKind ?? "graceful", + ...(options?.reason ? { reason: options.reason } : {}), + }, + }); + }); + + 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.", + }); + } + yield* ensureSupportedKimiVersion(input.threadId); + const mcp = McpProviderSession.readMcpProviderSession(input.threadId); + const existing = sessions.get(input.threadId); + if (existing) yield* stopSessionInternal(existing, { clearMcp: false }); + + const scope = yield* Scope.make("sequential"); + let transferred = false; + yield* Effect.addFinalizer(() => + transferred ? Effect.void : Scope.close(scope, Exit.void), + ); + const cwd = path.resolve(input.cwd.trim()); + const resumeSessionId = parseKimiResume(input.resumeCursor)?.sessionId; + const nativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger: options?.nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + const acp = yield* makeKimiAcpRuntime({ + kimiSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...nativeLoggers, + ...(mcp + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcp.endpoint, + headers: [{ name: "Authorization", value: mcp.authorizationHeader }], + }, + ], + } + : {}), + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to start the Kimi ACP runtime.", + cause, + }), + ), + ); + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + let context: KimiSessionContext | undefined; + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + const questions = extractKimiPermissionQuestions(params); + if (questions) { + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* publish({ + type: "user-input.requested", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: context?.activeTurnId, + requestId: RuntimeRequestId.make(requestId), + payload: { questions }, + raw: { + source: "acp.jsonrpc", + method: "session/request_permission", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* publish({ + type: "user-input.resolved", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: context?.activeTurnId, + requestId: RuntimeRequestId.make(requestId), + payload: { answers: resolved }, + raw: { + source: "acp.jsonrpc", + method: "session/request_permission", + payload: params, + }, + }); + const optionId = resolveKimiQuestionPermissionOption({ + request: params, + questions, + answers: resolved, + }); + return { + outcome: optionId + ? { outcome: "selected" as const, optionId } + : { outcome: "cancelled" as const }, + }; + } + + const permission = parsePermissionRequest(params); + const autoAcceptEdit = + input.runtimeMode === "auto-accept-edits" && + ["edit", "delete", "move"].includes(permission.kind); + if (input.runtimeMode === "full-access" || autoAcceptEdit) { + const optionId = selectAutoApprovedPermissionOption(params); + if (optionId) return { outcome: { outcome: "selected" as const, optionId } }; + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { decision, request: params }); + yield* publish( + makeAcpRequestOpenedEvent({ + stamp: yield* nextEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: context?.activeTurnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest: permission, + detail: permission.detail ?? "Kimi ACP permission request", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* publish( + makeAcpRequestResolvedEvent({ + stamp: yield* nextEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: context?.activeTurnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest: permission, + decision: resolved, + }), + ); + if (resolved === "cancel") return { outcome: { outcome: "cancelled" as const } }; + const optionId = permissionOptionIdForDecision(params, resolved); + if (!optionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Kimi did not advertise an ACP option for '${resolved}'.`, + cause: params, + }); + } + return { outcome: { outcome: "selected" as const, optionId } }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", cause), + ), + ); + + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + yield* applyKimiAcpModelSelection({ + runtime: acp, + model: modelSelection?.model, + selections: modelSelection?.options, + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + ), + ); + yield* applyKimiMode({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + threadId: input.threadId, + }); + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: modelSelection?.model, + threadId: input.threadId, + resumeCursor: { schemaVersion: KIMI_RESUME_VERSION, sessionId: started.sessionId }, + createdAt: now, + updatedAt: now, + }; + context = { + threadId: input.threadId, + acpSessionId: started.sessionId, + scope, + acp, + session, + preparingTurnId: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + interruptedTurnIds: new Set(), + turns: [], + pendingApprovals, + pendingUserInputs, + notificationFiber: undefined, + stoppedSignal: yield* Deferred.make(), + turnCompletionLock: yield* Semaphore.make(1), + supportsImages: initializedPromptSupportsImages(started.initializeResult), + stopped: false, + }; + const ownedContext = context; + ownedContext.notificationFiber = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if (ownedContext.stopped || sessions.get(ownedContext.threadId) !== ownedContext) + return; + const eventInput = { + stamp: yield* nextEventStamp(), + provider: PROVIDER, + threadId: ownedContext.threadId, + turnId: ownedContext.activeTurnId, + }; + switch (event._tag) { + case "AssistantItemStarted": + yield* publish( + makeAcpAssistantItemEvent({ + ...eventInput, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* publish( + makeAcpAssistantItemEvent({ + ...eventInput, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* publish( + makeAcpPlanUpdatedEvent({ + ...eventInput, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* publish( + makeAcpToolCallEvent({ + ...eventInput, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* publish( + makeAcpContentDeltaEvent({ + ...eventInput, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + default: + return; + } + }), + ), + ).pipe( + Effect.catch(() => Effect.void), + Effect.forkIn(ownedContext.scope), + ); + sessions.set(input.threadId, ownedContext); + transferred = true; + yield* publish({ + type: "session.started", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* publish({ + type: "session.state.changed", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Kimi ACP session ready" }, + }); + yield* publish({ + type: "thread.started", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + yield* Effect.exit(acp.processExit).pipe( + Effect.flatMap((processExit) => { + if (ownedContext.stopped || sessions.get(ownedContext.threadId) !== ownedContext) { + return Effect.void; + } + const reason = Exit.isSuccess(processExit) + ? `Kimi ACP process exited with code ${processExit.value}.` + : "Kimi ACP process exited unexpectedly."; + return stopSessionInternal(ownedContext, { exitKind: "error", reason }); + }), + Effect.catch(() => Effect.void), + Effect.forkIn(adapterScope), + ); + yield* Effect.yieldNow; + if (ownedContext.stopped || sessions.get(input.threadId) !== ownedContext) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Kimi ACP process exited during session startup.", + }); + } + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: KimiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + const steeringTurnId = + context.promptsInFlight > 0 + ? (context.activeTurnId ?? context.preparingTurnId) + : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + context.preparingTurnId = turnId; + context.promptsInFlight += 1; + return yield* Effect.gen(function* () { + const selection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = selection?.model ?? context.session.model; + const prompt: Array = []; + if (input.input?.trim()) prompt.push({ type: "text", text: input.input.trim() }); + for (const attachment of input.attachments ?? []) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + if (!context.supportsImages) { + prompt.push({ type: "text", text: `Attachment available at: ${attachmentPath}` }); + continue; + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Failed to read the Kimi prompt attachment.", + cause, + }), + ), + ); + prompt.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + if (prompt.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + if (sessions.get(input.threadId) !== context || context.stopped) { + return { threadId: input.threadId, turnId, resumeCursor: context.session.resumeCursor }; + } + if (context.interruptedTurnIds.has(turnId)) { + if (context.promptsInFlight === 1) context.interruptedTurnIds.delete(turnId); + return { threadId: input.threadId, turnId, resumeCursor: context.session.resumeCursor }; + } + context.preparingTurnId = undefined; + context.activeTurnId = turnId; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + if (!steeringTurnId) { + yield* publish({ + type: "turn.started", + ...(yield* nextEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + } + yield* applyKimiAcpModelSelection({ + runtime: context.acp, + model, + selections: selection?.options, + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + ), + ); + yield* applyKimiMode({ + runtime: context.acp, + runtimeMode: context.session.runtimeMode, + interactionMode: input.interactionMode, + threadId: input.threadId, + }); + context.session = { + ...context.session, + updatedAt: yield* nowIso, + model, + }; + if (context.interruptedTurnIds.has(turnId)) { + if (context.promptsInFlight === 1) { + context.interruptedTurnIds.delete(turnId); + yield* completeTurn({ + context, + turnId, + state: "cancelled", + stopReason: null, + finalizeSession: true, + }); + } + return { threadId: input.threadId, turnId, resumeCursor: context.session.resumeCursor }; + } + const result = yield* context.acp + .prompt({ prompt }) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), + ), + ); + yield* Effect.raceFirst(context.acp.drainEvents, Deferred.await(context.stoppedSignal)); + if ( + sessions.get(input.threadId) !== context || + context.stopped || + context.activeTurnId !== turnId + ) { + return { threadId: input.threadId, turnId, resumeCursor: context.session.resumeCursor }; + } + const record = context.turns.find((entry) => entry.id === turnId); + if (record) record.items.push({ prompt, result }); + else context.turns.push({ id: turnId, items: [{ prompt, result }] }); + if (context.promptsInFlight === 1) { + const interrupted = context.interruptedTurnIds.delete(turnId); + yield* completeTurn({ + context, + turnId, + state: interrupted || result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + finalizeSession: true, + ...(model ? { model } : {}), + }); + } + return { threadId: input.threadId, turnId, resumeCursor: context.session.resumeCursor }; + }).pipe( + Effect.onError(() => + (context.promptsInFlight === 1 + ? Effect.gen(function* () { + context.interruptedTurnIds.delete(turnId); + yield* completeTurn({ + context, + turnId, + state: "failed", + stopReason: null, + finalizeSession: true, + }); + }) + : Effect.void + ).pipe(Effect.ignore), + ), + Effect.ensuring( + Effect.sync(() => { + context.promptsInFlight = Math.max(0, context.promptsInFlight - 1); + if (context.promptsInFlight === 0 && context.preparingTurnId === turnId) { + context.preparingTurnId = undefined; + } + }), + ), + ); + }); + + const interruptTurn: KimiAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const activeTurnId = turnId ?? context.activeTurnId ?? context.preparingTurnId; + if (activeTurnId) context.interruptedTurnIds.add(activeTurnId); + yield* settleApprovals(context.pendingApprovals); + yield* settleUserInputs(context.pendingUserInputs); + yield* Effect.ignore( + context.acp.cancel.pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", cause), + ), + ), + ); + }); + const respondToRequest: KimiAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingApprovals.get(requestId); + if (!pending) + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + if (decision !== "cancel" && !permissionOptionIdForDecision(pending.request, decision)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Kimi did not advertise an ACP option for '${decision}'.`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + const respondToUserInput: KimiAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + const readThread: KimiAdapterShape["readThread"] = (threadId) => + Effect.map(requireSession(threadId), (context) => ({ + threadId, + turns: context.turns.map((turn) => ({ id: turn.id, items: [...turn.items] })), + })); + const rollbackThread: KimiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + if (!Number.isInteger(numTurns) || numTurns < 1) + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: "Kimi ACP sessions do not support provider-side rollback.", + }); + }); + const stopSession: KimiAdapterShape["stopSession"] = (threadId) => + withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopSessionInternal)); + const listSessions: KimiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (context) => ({ ...context.session }))); + const hasSession: KimiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const context = sessions.get(threadId); + return context !== undefined && !context.stopped; + }); + const stopAll: KimiAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), (context) => stopSessionInternal(context), { + discard: true, + }); + + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.catch(() => Effect.void), + Effect.andThen(PubSub.shutdown(runtimeEvents)), + ), + ); + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromPubSub(runtimeEvents), + } 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..6b56b7806012 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.test.ts @@ -0,0 +1,438 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import type * 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 type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import type { KimiSettings } from "@t3tools/contracts"; +import { describe, expect } from "vite-plus/test"; + +import { + buildInitialKimiProviderSnapshot, + checkKimiProviderStatus, + kimiModelCapabilitiesFromConfigOptions, + kimiModelStateFromSessionSetup, +} from "./KimiProvider.ts"; + +const withNodeServices = ( + effect: Effect.Effect< + A, + E, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path + >, +) => effect.pipe(Effect.provide(NodeServices.layer)); +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +const kimiSettings = (overrides: Partial = {}): KimiSettings => ({ + enabled: true, + binaryPath: "kimi", + homePath: "", + launchArgs: "", + customModels: [], + ...overrides, +}); + +type KimiFixtureMode = "ready" | "unsupported" | "unauthenticated" | "failure"; + +const makeKimiFixture = Effect.fn("makeKimiFixture")(function* ( + mode: KimiFixtureMode, + version = "1.2.3", +) { + const platform = yield* HostProcessPlatform; + return yield* Effect.promise(async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kimi-provider-")); + const agentPath = NodePath.join(directory, "kimi-acp-fixture.mjs"); + const binaryPath = NodePath.join(directory, platform === "win32" ? "kimi.cmd" : "kimi"); + await NodeFSP.writeFile( + agentPath, + `import * as readline from "node:readline"; + +const mode = process.env.T3_KIMI_FIXTURE_MODE; +const omitPlanMode = process.env.T3_KIMI_FIXTURE_OMIT_PLAN_MODE === "1"; +let currentModel = "kimi-code/kimi-for-coding"; +const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); +const fail = (id, code, message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\\n"); +const notify = (method, params) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\\n"); +const configOptions = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: currentModel, options: [{ value: "kimi-code/kimi-for-coding", name: "K2.7 Coding" }, { value: "kimi-code/kimi-for-coding-highspeed", name: "K2.7 Coding Highspeed" }, { value: "kimi-code/k3", name: "K3" }, { value: "kimi-code/k3-256k", name: "K3-256k" }] }, + { id: "mode", name: "Mode", category: "mode", type: "select", currentValue: "default", options: [{ value: "default", name: "Default" }, { value: "plan", name: "Plan" }] }, + ...(currentModel === "kimi-code/k3" || currentModel === "kimi-code/k3-256k" + ? [{ id: "thinking", name: "Thinking", category: "thought_level", type: "select", currentValue: "high", options: [{ value: "low", name: "Low" }, { value: "high", name: "High" }, { value: "max", name: "Max" }] }] + : [{ id: "thinking", name: "Thinking", category: "thought_level", type: "select", currentValue: "on", options: [{ value: "on", name: "On" }] }]) +]; + +for await (const line of readline.createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.method === "initialize") { + if (mode === "unsupported") fail(request.id, -32601, "ACP protocol is not supported"); + else reply(request.id, { protocolVersion: 1, agentCapabilities: { loadSession: true } }); + continue; + } + if (request.method === "authenticate") { + if (mode === "unauthenticated") fail(request.id, -32000, "Kimi login required"); + else reply(request.id, {}); + continue; + } + if (request.method === "session/new") { + if (mode === "failure") process.exit(7); + reply(request.id, { + sessionId: "kimi-fixture-session", + modes: { + currentModeId: "default", + availableModes: omitPlanMode + ? [{ id: "default", name: "Default" }] + : [{ id: "default", name: "Default" }, { id: "plan", name: "Plan" }] + }, + configOptions: configOptions() + }); + notify("session/update", { + sessionId: "kimi-fixture-session", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "review", description: "Review the current change", input: { hint: "scope" } }, + { name: "ship", description: "Prepare the current change" } + ] + } + }); + continue; + } + if (request.method === "session/set_config_option") { + if (request.params.configId === "model") currentModel = request.params.value; + reply(request.id, { configOptions: configOptions() }); + continue; + } +} +`, + "utf8", + ); + const binary = + platform === "win32" + ? `@echo off +if "%~1"=="--version" ( + echo kimi ${version} + exit /b 0 +) +"${process.execPath}" "${agentPath}" %* +` + : `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'kimi ${version}\\n' + exit 0 +fi +exec ${encodeJsonString(process.execPath)} ${encodeJsonString(agentPath)} "$@" +`; + await NodeFSP.writeFile(binaryPath, binary, "utf8"); + await NodeFSP.chmod(binaryPath, 0o755); + return binaryPath; + }); +}); + +describe("buildInitialKimiProviderSnapshot", () => { + it.effect("returns a disabled snapshot when Kimi is disabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot(kimiSettings({ enabled: false })); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.showInteractionModeToggle).toBe(false); + expect(snapshot.installed).toBe(false); + }), + ); +}); + +describe("kimiModelCapabilitiesFromConfigOptions", () => { + it("keeps advertised Kimi option ids and excludes model and mode selectors", () => { + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-k2", + options: [{ value: "kimi-k2", name: "Kimi K2" }], + }, + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Default" }], + }, + { + id: "thinking", + name: "Thinking", + category: "model_config", + type: "boolean", + currentValue: true, + }, + ] satisfies ReadonlyArray; + + expect(kimiModelCapabilitiesFromConfigOptions(configOptions)).toEqual({ + optionDescriptors: [ + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + }); + }); +}); + +describe("kimiModelStateFromSessionSetup", () => { + it("uses the generic ACP model option and ignores blank and duplicate entries", () => { + expect( + kimiModelStateFromSessionSetup({ + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-code/k3", + options: [ + { value: "", name: "Blank" }, + { value: "kimi-code/k3", name: "K3" }, + { value: "kimi-code/k3", name: "Duplicate" }, + ], + }, + ], + }), + ).toEqual({ + currentModelId: "kimi-code/k3", + availableModels: [{ modelId: "kimi-code/k3", name: "K3" }], + }); + }); + + it("falls back to legacy ACP model state when no model option is advertised", () => { + const models = { + currentModelId: "kimi-k2", + availableModels: [ + { modelId: "kimi-k2", name: "Kimi K2" }, + { modelId: "kimi-k2-thinking", name: "Kimi K2 Thinking" }, + ], + } satisfies EffectAcpSchema.SessionModelState; + + expect(kimiModelStateFromSessionSetup({ models })).toEqual({ + currentModelId: "kimi-k2", + availableModels: models.availableModels, + }); + }); + + it("falls back to legacy ACP model state when the model option has no usable values", () => { + const models = { + currentModelId: "kimi-k2", + availableModels: [{ modelId: "kimi-k2", name: "Kimi K2" }], + } satisfies EffectAcpSchema.SessionModelState; + + expect( + kimiModelStateFromSessionSetup({ + models, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "", + options: [{ value: " ", name: "Blank" }], + }, + ], + }), + ).toEqual({ + currentModelId: "kimi-k2", + availableModels: models.availableModels, + }); + }); +}); + +describe("checkKimiProviderStatus", () => { + it.effect("reports a missing Kimi binary", () => + Effect.gen(function* () { + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath: "/definitely/not/installed/kimi" })), + ); + + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("not installed"); + }), + ); + + it.effect("reports ACP protocol support separately from a missing binary", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("unsupported"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "unsupported", + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.message).toContain("ACP"); + }), + ); + + it.effect("rejects Kimi versions that cannot expose selectable thinking levels", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("ready", "0.28.1"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "ready", + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.version).toBe("0.28.1"); + expect(snapshot.message).toContain("0.29.0"); + expect(snapshot.message).toContain("thinking levels"); + }), + ); + + it.effect("rejects prereleases older than the minimum stable Kimi version", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("ready", "0.29.0-beta.1"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "ready", + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.version).toBe("0.29.0-beta.1"); + }), + ); + + it.effect("rejects Kimi output whose version cannot be verified", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("ready", "unknown"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "ready", + }), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Unable to determine Kimi version"); + expect(snapshot.message).toContain("0.29.0"); + }), + ); + + it.effect("reports authentication requirements with the Kimi login command", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("unauthenticated"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "unauthenticated", + }), + ); + + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("kimi login"); + }), + ); + + it.effect("reports unexpected ACP startup failures without treating Kimi as missing", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("failure"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "failure", + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).toContain("ACP startup failed"); + }), + ); + + it.effect("discovers models, options, modes, and commands without prompting", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("ready", "0.29.0"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus( + kimiSettings({ + binaryPath, + customModels: ["custom-kimi", "custom-kimi", "kimi-code/k3"], + }), + { ...process.env, T3_KIMI_FIXTURE_MODE: "ready" }, + ), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.badgeLabel).toBe("Early Access"); + expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "kimi-code/kimi-for-coding", + "kimi-code/kimi-for-coding-highspeed", + "kimi-code/k3", + "kimi-code/k3-256k", + "custom-kimi", + ]); + expect(snapshot.models[0]).toMatchObject({ isDefault: true, isCustom: false }); + expect(snapshot.models[0]?.capabilities).toEqual({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + currentValue: "on", + options: [{ id: "on", label: "On", isDefault: true }], + }, + ], + }); + expect(snapshot.models[1]).toMatchObject({ + slug: "kimi-code/kimi-for-coding-highspeed", + name: "K2.7 Coding Highspeed", + isCustom: false, + }); + expect(snapshot.models[2]?.capabilities).toEqual({ + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + ], + }, + ], + }); + expect(snapshot.slashCommands).toEqual([ + { name: "review", description: "Review the current change", input: { hint: "scope" } }, + { name: "ship", description: "Prepare the current change" }, + ]); + }), + ); + + it.effect("hides plan mode when Kimi does not advertise it", () => + Effect.gen(function* () { + const binaryPath = yield* makeKimiFixture("ready", "0.29.0"); + const snapshot = yield* withNodeServices( + checkKimiProviderStatus(kimiSettings({ binaryPath }), { + ...process.env, + T3_KIMI_FIXTURE_MODE: "ready", + T3_KIMI_FIXTURE_OMIT_PLAN_MODE: "1", + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.showInteractionModeToggle).toBe(false); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiProvider.ts b/apps/server/src/provider/Layers/KimiProvider.ts new file mode 100644 index 000000000000..05657f12e35e --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.ts @@ -0,0 +1,551 @@ +import type { + KimiSettings, + ModelCapabilities, + ProviderOptionDescriptor, + ServerProvider, + ServerProviderModel, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +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 Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import type * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { makeKimiEnvironment } from "../Drivers/KimiHome.ts"; +import { discoverKimiSkills } from "../Drivers/KimiSkills.ts"; +import { + getKimiCliCompatibilityIssue, + parseKimiCliVersion, + runKimiVersionCommand, +} from "../Drivers/KimiVersion.ts"; +import { makeKimiAcpRuntime } from "../acp/KimiAcpSupport.ts"; +import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const KIMI_PRESENTATION = { + displayName: "Kimi", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const KIMI_ACP_DISCOVERY_TIMEOUT_MS = 15_000; + +interface KimiAcpDiscovery { + readonly currentModelId: string | undefined; + readonly availableModels: ReadonlyArray; + readonly configOptions: ReadonlyArray; + readonly capabilitiesByModel: ReadonlyMap; + readonly commands: ReadonlyArray; + readonly supportsPlanMode: boolean; +} + +type KimiAcpFailure = "unauthenticated" | "unsupported" | "failure"; + +interface KimiAcpAuthProbeState { + readonly started: Ref.Ref; + readonly succeeded: Ref.Ref; +} + +export function buildInitialKimiProviderSnapshot( + kimiSettings: KimiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getKimiFallbackModels(kimiSettings); + 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...", + }, + }); + }); +} + +export function kimiModelCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + if (!configOptions) { + return EMPTY_CAPABILITIES; + } + const optionDescriptors: Array = []; + for (const option of configOptions) { + const id = option.id.trim(); + const label = option.name.trim(); + if (!id || !label || option.category === "model" || option.category === "mode") { + continue; + } + if (option.type === "boolean") { + optionDescriptors.push( + typeof option.currentValue === "boolean" + ? buildBooleanOptionDescriptor({ id, label, currentValue: option.currentValue }) + : buildBooleanOptionDescriptor({ id, label }), + ); + continue; + } + const options: Array<{ value: string; label: string; isDefault?: boolean }> = []; + for (const entry of option.options) { + const values = "value" in entry ? [entry] : entry.options; + for (const valueOption of values) { + const value = valueOption.value.trim(); + if (!value) { + continue; + } + options.push({ + value, + label: valueOption.name.trim() || value, + ...(option.currentValue?.trim() === value ? { isDefault: true } : {}), + }); + } + } + if (options.length > 0) { + optionDescriptors.push(buildSelectOptionDescriptor({ id, label, options })); + } + } + return createModelCapabilities({ optionDescriptors }); +} + +export function kimiModelStateFromSessionSetup(input: { + readonly models?: EffectAcpSchema.SessionModelState | null | undefined; + readonly configOptions?: ReadonlyArray | null | undefined; +}): { + readonly currentModelId: string | undefined; + readonly availableModels: ReadonlyArray; +} { + const modelOption = input.configOptions?.find( + (option) => option.category === "model" && option.type === "select", + ); + if (modelOption?.type === "select") { + const seen = new Set(); + const availableModels: Array = []; + for (const entry of modelOption.options) { + const valueOptions = "value" in entry ? [entry] : entry.options; + for (const valueOption of valueOptions) { + const modelId = valueOption.value.trim(); + if (!modelId || seen.has(modelId)) continue; + seen.add(modelId); + availableModels.push({ + modelId, + name: valueOption.name.trim() || modelId, + }); + } + } + if (availableModels.length > 0) { + return { + currentModelId: modelOption.currentValue?.trim() || undefined, + availableModels, + }; + } + } + return { + currentModelId: input.models?.currentModelId.trim() || undefined, + availableModels: input.models?.availableModels ?? [], + }; +} + +function getKimiFallbackModels( + kimiSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], kimiSettings.customModels, EMPTY_CAPABILITIES); +} + +function discoveredKimiModels(input: { + readonly currentModelId: string | undefined; + readonly availableModels: ReadonlyArray; + readonly capabilitiesByModel: ReadonlyMap; +}): ReadonlyArray { + const currentModelId = input.currentModelId?.trim(); + const seen = new Set(); + return input.availableModels.flatMap((model) => { + const slug = model.modelId.trim(); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(currentModelId === slug ? { isDefault: true } : {}), + capabilities: input.capabilitiesByModel.get(slug) ?? EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function kimiSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return commands.flatMap((command) => { + const name = command.name.trim(); + if (!name || seen.has(name)) { + return []; + } + seen.add(name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }); +} + +const discoverKimiViaAcp = ( + settings: KimiSettings, + environment: NodeJS.ProcessEnv, + cwd: string, + authProbeState: KimiAcpAuthProbeState, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeKimiAcpRuntime({ + kimiSettings: settings, + environment, + childProcessSpawner, + cwd, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + requestLogger: (event) => + event.method !== "authenticate" + ? Effect.void + : event.status === "started" + ? Ref.set(authProbeState.started, true) + : event.status === "succeeded" + ? Ref.set(authProbeState.succeeded, true) + : Effect.void, + }); + yield* runtime.getEvents().pipe( + Stream.runForEach((event) => + event._tag === "EventStreamBarrier" + ? Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ); + const started = yield* runtime.start(); + yield* runtime.drainEvents; + const initialConfigOptions = yield* runtime.getConfigOptions; + const { currentModelId, availableModels } = kimiModelStateFromSessionSetup({ + models: started.sessionSetupResult.models, + configOptions: initialConfigOptions, + }); + const capabilitiesByModel = new Map(); + for (const model of availableModels) { + const modelId = model.modelId.trim(); + if (!modelId) continue; + if (modelId === currentModelId) { + capabilitiesByModel.set( + modelId, + kimiModelCapabilitiesFromConfigOptions(initialConfigOptions), + ); + continue; + } + const modelConfigOptions = yield* runtime.setModel(modelId).pipe( + Effect.andThen(runtime.getConfigOptions), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + capabilitiesByModel.set(modelId, kimiModelCapabilitiesFromConfigOptions(modelConfigOptions)); + } + if ( + currentModelId && + availableModels.some((model) => model.modelId.trim() === currentModelId) + ) { + yield* runtime.setModel(currentModelId).pipe(Effect.ignore); + } + const modeState = yield* runtime.getModeState; + return { + currentModelId, + availableModels, + configOptions: initialConfigOptions, + capabilitiesByModel, + commands: yield* runtime.getAvailableCommands, + supportsPlanMode: + modeState?.availableModes.some((mode) => + ["plan", "architect"].includes(mode.id.trim().toLowerCase()), + ) ?? false, + } satisfies KimiAcpDiscovery; + }).pipe(Effect.scoped); + +function classifyKimiAcpFailure( + cause: Cause.Cause, + authProbe: { readonly started: boolean; readonly succeeded: boolean }, +): KimiAcpFailure { + if (authProbe.started && !authProbe.succeeded) { + return "unauthenticated"; + } + const requestErrors = cause.reasons.flatMap((reason) => { + if (Cause.isFailReason(reason) && isAcpRequestError(reason.error)) { + return [reason.error]; + } + if (Cause.isDieReason(reason) && isAcpRequestError(reason.defect)) { + return [reason.defect]; + } + return []; + }); + if ( + requestErrors.some( + (error) => + error.method === "authenticate" || + error.code === -32000 || + /auth|login|credential/i.test(error.errorMessage), + ) + ) { + return "unauthenticated"; + } + if ( + requestErrors.some( + (error) => + error.code === -32601 || /unsupported|protocol|method not found/i.test(error.errorMessage), + ) + ) { + return "unsupported"; + } + return "failure"; +} + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +export const checkKimiProviderStatus = Effect.fn("checkKimiProviderStatus")(function* ( + settings: KimiSettings, + baseEnvironment: NodeJS.ProcessEnv = process.env, + cwd = process.cwd(), +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getKimiFallbackModels(settings); + if (!settings.enabled) { + return yield* buildInitialKimiProviderSnapshot(settings); + } + const environment = yield* makeKimiEnvironment(settings, baseEnvironment); + const versionResult = yield* runKimiVersionCommand(settings, 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: true, + 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." + : "Failed to execute Kimi CLI health check.", + }, + }); + } + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + 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 = parseKimiCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but failed to run.", + }, + }); + } + const compatibilityIssue = getKimiCliCompatibilityIssue(version); + if (compatibilityIssue !== null) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: compatibilityIssue, + }, + }); + } + const authProbeState = { + started: yield* Ref.make(false), + succeeded: yield* Ref.make(false), + } satisfies KimiAcpAuthProbeState; + const discoveryExit = yield* Effect.exit( + discoverKimiViaAcp(settings, environment, cwd, authProbeState).pipe( + Effect.timeoutOption(KIMI_ACP_DISCOVERY_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(discoveryExit)) { + const failure = classifyKimiAcpFailure(discoveryExit.cause, { + started: yield* Ref.get(authProbeState.started), + succeeded: yield* Ref.get(authProbeState.succeeded), + }); + yield* Effect.logWarning("Kimi ACP discovery failed.", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + const message = + failure === "unauthenticated" + ? "Kimi is not authenticated. Run `kimi login` and try again." + : failure === "unsupported" + ? "Kimi CLI is installed but does not support the ACP protocol." + : "Kimi CLI is installed but ACP startup failed. Check server logs for details."; + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: failure === "unauthenticated" ? { status: "unauthenticated" } : { status: "unknown" }, + message, + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Kimi ACP discovery timed out after ${KIMI_ACP_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discovery = discoveryExit.value.value; + const customModelCapabilities = kimiModelCapabilitiesFromConfigOptions(discovery.configOptions); + const discoveredModels = discoveredKimiModels(discovery); + const models = providerModelsFromSettings( + discoveredModels, + settings.customModels, + customModelCapabilities, + ); + const skills = yield* discoverKimiSkills(settings, cwd, environment); + return buildServerProvider({ + presentation: { + ...KIMI_PRESENTATION, + showInteractionModeToggle: discovery.supportsPlanMode, + }, + enabled: true, + checkedAt, + models: models.length > 0 ? models : fallbackModels, + slashCommands: kimiSlashCommands(discovery.commands), + skills, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const enrichKimiSnapshot = (input: { + readonly settings: KimiSettings; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + if (!input.settings.enabled || input.snapshot.auth.status === "unauthenticated") { + return Effect.void; + } + return enrichProviderSnapshotWithVersionAdvisory(input.snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap(input.publishSnapshot), + Effect.catchCause((cause) => + Effect.logWarning("Kimi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a3..55c0fc5e9954 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1442,6 +1442,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + kimi: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1807,6 +1808,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "kimi", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/Services/KimiAdapter.ts b/apps/server/src/provider/Services/KimiAdapter.ts new file mode 100644 index 000000000000..ada1778c2b92 --- /dev/null +++ b/apps/server/src/provider/Services/KimiAdapter.ts @@ -0,0 +1,9 @@ +/** + * KimiAdapter - per-instance Kimi ACP adapter contract. + * + * @module KimiAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface KimiAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..1cac72a64c2a 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -116,6 +116,138 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("replaces available command snapshots while preserving update events", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "show commands" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 2))); + expect(events).toEqual([ + { + _tag: "AvailableCommandsChanged", + commands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + { + _tag: "AvailableCommandsChanged", + commands: [ + { + name: "skill:ship", + description: "Prepare the current change for delivery", + }, + ], + }, + ]); + const commands = yield* runtime.getAvailableCommands; + expect(commands).toEqual([ + { + name: "skill:ship", + description: "Prepare the current change for delivery", + }, + ]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_AVAILABLE_COMMAND_UPDATES: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("retains session updates emitted before session creation returns", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + expect(yield* runtime.getAvailableCommands).toEqual([ + { name: "skill:startup", description: "Available during session creation" }, + ]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_EMIT_AVAILABLE_COMMANDS_DURING_CREATE: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("clears available command snapshots while preserving empty update events", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "clear commands" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 2))); + expect(events).toEqual([ + { + _tag: "AvailableCommandsChanged", + commands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + { + _tag: "AvailableCommandsChanged", + commands: [], + }, + ]); + const commands = yield* runtime.getAvailableCommands; + expect(commands).toEqual([]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_AVAILABLE_COMMAND_CLEAR_UPDATES: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("keeps assistant item IDs unique when a provider session restarts", () => { const collectFirstAssistantItemId = Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -420,6 +552,44 @@ describe("AcpSessionRuntime", () => { ); }); + it.effect("uses standard ACP model switching when no model config option is advertised", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.setModel("grok-mock-alt"); + + expect( + requestEvents.some( + (event) => event.method === "session/set_model" && event.status === "succeeded", + ), + ).toBe(true); + expect(requestEvents.some((event) => event.method === "session/set_config_option")).toBe( + false, + ); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_OMIT_MODEL_CONFIG: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("skips no-op session config writes when the requested value is already active", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -529,6 +699,113 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect( + "uses session/load without attempting resume when the agent did not advertise it", + () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + + expect(started.sessionId).toBe("mock-session-1"); + expect( + requestEvents.filter((event) => event.status === "started").map((event) => event.method), + ).toEqual(["initialize", "authenticate", "session/load"]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + resumeStrategy: "resume-first", + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }, + ); + + it.effect("falls back to session/load only when an advertised resume method is missing", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + + expect(started.sessionId).toBe("mock-session-1"); + expect( + requestEvents.filter((event) => event.status === "started").map((event) => event.method), + ).toEqual(["initialize", "authenticate", "session/resume", "session/load"]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_ADVERTISE_RESUME: "1" }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + resumeStrategy: "resume-first", + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("propagates an advertised resume failure other than method-not-found", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const error = yield* runtime.start().pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "AcpRequestError", code: -32602 }); + expect( + requestEvents.filter((event) => event.status === "started").map((event) => event.method), + ).toEqual(["initialize", "authenticate", "session/resume"]); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_ADVERTISE_RESUME: "1", + T3_ACP_FAIL_RESUME_SESSION: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + resumeStrategy: "resume-first", + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("ignores session/update replay notifications during session/load", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..42f4894be83b 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -273,6 +273,68 @@ describe("AcpRuntimeModel", () => { ]); }); + it("projects available command updates into runtime events", () => { + const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(parsed.events).toEqual([ + { + _tag: "AvailableCommandsChanged", + commands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + ]); + }); + + it("trims and drops empty available commands", () => { + const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { + name: " skill:review ", + description: " Review the current change ", + input: { hint: "scope" }, + }, + { + name: " ", + description: "Missing command name", + }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(parsed.events).toEqual([ + { + _tag: "AvailableCommandsChanged", + commands: [ + { + name: "skill:review", + description: "Review the current change", + input: { hint: "scope" }, + }, + ], + }, + ]); + }); + it("projects typed ACP plan and content updates", () => { const planResult = parseSessionUpdateEvent({ sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..301949b58bb0 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -81,6 +81,10 @@ export interface AcpPermissionRequest { } export type AcpParsedSessionEvent = + | { + readonly _tag: "AvailableCommandsChanged"; + readonly commands: ReadonlyArray; + } | { readonly _tag: "ModeChanged"; readonly modeId: string; @@ -514,6 +518,20 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat let modeId: string | undefined; switch (upd.sessionUpdate) { + case "available_commands_update": { + const commands = upd.availableCommands.flatMap((command) => { + const name = command.name.trim(); + if (!name) { + return []; + } + return [{ ...command, name, description: command.description.trim() }]; + }); + events.push({ + _tag: "AvailableCommandsChanged", + commands, + }); + break; + } case "current_mode_update": { modeId = upd.currentModeId.trim(); if (modeId) { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..35a27218a2ee 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -61,6 +61,8 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + /** Prefer ACP's lightweight session/resume method, then fall back to session/load. */ + readonly resumeStrategy?: "load" | "resume-first"; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; @@ -183,8 +185,12 @@ export class AcpSessionRuntime extends Context.Service< readonly getEvents: () => Stream.Stream; /** Waits until the current event consumer has processed every queued event. */ readonly drainEvents: Effect.Effect; + /** Resolves when the ACP child process exits. */ + readonly processExit: Effect.Effect; /** Latest mode state observed from session setup and `session/update` notifications. */ readonly getModeState: Effect.Effect; + /** Latest available commands observed from `session/update` notifications. */ + readonly getAvailableCommands: Effect.Effect>; /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; /** @@ -253,6 +259,7 @@ type AcpStartState = | { readonly _tag: "Starting"; readonly deferred: Deferred.Deferred; + readonly pendingUpdates: ReadonlyArray; } | { readonly _tag: "Started"; readonly result: AcpStartedState }; @@ -279,6 +286,9 @@ export const make = ( const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); + const availableCommandsRef = yield* Ref.make>( + [], + ); const toolCallsRef = yield* Ref.make(new Map()); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( @@ -384,18 +394,25 @@ export const make = ( if (sessionUpdateIsReplay(notification)) { return; } - const startState = yield* Ref.get(startStateRef); - // One runtime projects one root ACP session. Child-session updates need - // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { - return; - } + const shouldHandle = yield* Ref.modify(startStateRef, (startState) => { + if (startState._tag === "Starting") { + return [ + false, + { ...startState, pendingUpdates: [...startState.pendingUpdates, notification] }, + ] as const; + } + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + return [ + startState._tag === "Started" && notification.sessionId === startState.result.sessionId, + startState, + ] as const; + }); + if (!shouldHandle) return; yield* handleSessionUpdate({ queue: eventQueue, modeStateRef, + availableCommandsRef, toolCallsRef, assistantSegmentRef, assistantItemRuntimeId, @@ -528,6 +545,23 @@ export const make = ( ), ); + const setSessionModel = ( + modelId: string, + ): Effect.Effect => + getStartedState.pipe( + Effect.flatMap((started) => { + const requestPayload = { + sessionId: started.sessionId, + modelId, + } satisfies EffectAcpSchema.SetSessionModelRequest; + return runLoggedRequest( + "session/set_model", + requestPayload, + acp.agent.setSessionModel(requestPayload), + ); + }), + ); + const startOnce = Effect.gen(function* () { const initializePayload = { protocolVersion: 1, @@ -562,6 +596,61 @@ export const make = ( cwd: options.cwd, mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; + const resumeCapability = initializeResult.agentCapabilities?.sessionCapabilities?.resume; + const resumed = + options.resumeStrategy === "resume-first" && + resumeCapability !== null && + resumeCapability !== undefined + ? yield* Effect.gen(function* () { + yield* logRequest({ + method: "session/resume", + payload: loadPayload, + status: "started", + }); + return yield* acp.agent.resumeSession(loadPayload).pipe( + Effect.tap((result) => + logRequest({ + method: "session/resume", + payload: loadPayload, + status: "succeeded", + result, + }), + ), + Effect.catchCause((cause) => + logRequest({ + method: "session/resume", + payload: loadPayload, + status: "failed", + cause, + }).pipe( + Effect.andThen(() => { + const [reason] = cause.reasons; + return cause.reasons.length === 1 && + reason !== undefined && + Cause.isFailReason(reason) && + reason.error._tag === "AcpRequestError" && + reason.error.code === -32601 + ? Effect.void + : Effect.failCause(cause); + }), + ), + ), + ); + }) + : undefined; + if (resumed) { + sessionId = options.resumeSessionId; + sessionSetupResult = resumed; + yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + const nextState = { + sessionId, + initializeResult, + sessionSetupResult, + modelConfigId: extractModelConfigId(sessionSetupResult), + } satisfies AcpStartedState; + return nextState; + } const sessionLoadTimeout = Duration.fromInputUnsafe( options.sessionLoadTimeout ?? defaultSessionLoadTimeout, ); @@ -671,9 +760,29 @@ export const make = ( return [ startOnce.pipe( Effect.tap((result) => - Ref.set(startStateRef, { _tag: "Started", result }).pipe( - Effect.andThen(Deferred.succeed(deferred, result)), - ), + Effect.gen(function* () { + const pendingUpdates = yield* Ref.modify( + startStateRef, + (state) => + [ + state._tag === "Starting" ? state.pendingUpdates : [], + { _tag: "Started", result } satisfies AcpStartState, + ] as const, + ); + for (const notification of pendingUpdates) { + if (notification.sessionId !== result.sessionId) continue; + yield* handleSessionUpdate({ + queue: eventQueue, + modeStateRef, + availableCommandsRef, + toolCallsRef, + assistantSegmentRef, + assistantItemRuntimeId, + params: notification, + }); + } + yield* Deferred.succeed(deferred, result); + }), ), Effect.onError((cause) => Deferred.failCause(deferred, cause).pipe( @@ -681,7 +790,7 @@ export const make = ( ), ), ), - { _tag: "Starting", deferred } satisfies AcpStartState, + { _tag: "Starting", deferred, pendingUpdates: [] } satisfies AcpStartState, ] as const; } }); @@ -714,7 +823,18 @@ export const make = ( }); yield* Deferred.await(acknowledge); }), + processExit: child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "ACP child process exit could not be observed.", + cause, + }), + ), + ), getModeState: Ref.get(modeStateRef), + getAvailableCommands: Ref.get(availableCommandsRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => promptSerializationSemaphore.withPermit( @@ -786,23 +906,21 @@ export const make = ( setConfigOption, setModel: (model) => getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.flatMap((started) => + started.modelConfigId + ? setConfigOption(started.modelConfigId, model) + : started.sessionSetupResult.models + ? setSessionModel(model) + : Effect.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "ACP agent does not advertise a writable model selector", + }), + ), + ), Effect.asVoid, ), - setSessionModel: (modelId) => - getStartedState.pipe( - Effect.flatMap((started) => { - const requestPayload = { - sessionId: started.sessionId, - modelId, - } satisfies EffectAcpSchema.SetSessionModelRequest; - return runLoggedRequest( - "session/set_model", - requestPayload, - acp.agent.setSessionModel(requestPayload), - ); - }), - ), + setSessionModel, request: (method, payload) => runLoggedRequest(method, payload, acp.raw.request(method, payload)), notify: acp.raw.notify, @@ -844,6 +962,7 @@ function configOptionCurrentValueMatches( const handleSessionUpdate = ({ queue, modeStateRef, + availableCommandsRef, toolCallsRef, assistantSegmentRef, assistantItemRuntimeId, @@ -851,6 +970,7 @@ const handleSessionUpdate = ({ }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; + readonly availableCommandsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; @@ -864,6 +984,9 @@ const handleSessionUpdate = ({ ); } for (const event of parsed.events) { + if (event._tag === "AvailableCommandsChanged") { + yield* Ref.set(availableCommandsRef, event.commands); + } if (event._tag === "ToolCallUpdated") { yield* closeActiveAssistantSegment({ queue, diff --git a/apps/server/src/provider/acp/KimiAcpCliProbe.test.ts b/apps/server/src/provider/acp/KimiAcpCliProbe.test.ts new file mode 100644 index 000000000000..f0f6752b866a --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpCliProbe.test.ts @@ -0,0 +1,33 @@ +/** + * Optional non-billing integration check against a real `kimi acp` install. + * Enable with: T3_KIMI_ACP_PROBE=1 pnpm exec vp test run KimiAcpCliProbe + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe, expect } from "vite-plus/test"; + +import { makeKimiAcpRuntime } from "./KimiAcpSupport.ts"; + +const makeProbeRuntime = Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* makeKimiAcpRuntime({ + kimiSettings: { binaryPath: "kimi", launchArgs: "" }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-kimi-probe", version: "0.0.0" }, + }); +}); + +describe.runIf(process.env.T3_KIMI_ACP_PROBE === "1")("Kimi ACP CLI probe", () => { + it.effect("initializes, authenticates, and opens a throwaway ACP session without prompting", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + expect(typeof started.sessionId).toBe("string"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); 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..77fdcd3ea803 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.test.ts @@ -0,0 +1,151 @@ +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { describe, expect } from "vite-plus/test"; + +import { applyKimiAcpModelSelection, buildKimiAcpSpawnInput } from "./KimiAcpSupport.ts"; + +describe("buildKimiAcpSpawnInput", () => { + it.each([null, undefined])("uses the default Kimi ACP command for %s settings", (settings) => { + expect(buildKimiAcpSpawnInput(settings, "/repo")).toEqual({ + command: "kimi", + args: ["acp"], + cwd: "/repo", + }); + }); + + it("puts tokenized global launch arguments before the Kimi ACP subcommand", () => { + expect( + buildKimiAcpSpawnInput( + { binaryPath: "/opt/kimi", launchArgs: "--agent coder --skills-dir 'team skills'" }, + "/repo", + { KIMI_CODE_HOME: "/homes/work" }, + ), + ).toEqual({ + command: "/opt/kimi", + args: ["--agent", "coder", "--skills-dir", "team skills", "acp"], + cwd: "/repo", + env: { KIMI_CODE_HOME: "/homes/work" }, + }); + }); +}); + +describe("applyKimiAcpModelSelection", () => { + it.effect( + "sets the requested model before applying only compatible advertised option selections", + () => + Effect.gen(function* () { + const calls: Array< + | { readonly type: "model"; readonly value: string } + | { readonly type: "config"; readonly id: string; readonly value: string | boolean } + > = []; + const configOptions: ReadonlyArray = [ + { + id: "reasoning", + name: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + { + id: "auto-approve", + name: "Auto approve", + type: "boolean", + currentValue: false, + }, + ]; + const runtime = { + getConfigOptions: Effect.sync(() => configOptions), + setModel: (value: string) => + Effect.sync(() => { + calls.push({ type: "model", value }); + }), + setConfigOption: (id: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ type: "config", id, value }); + }), + }; + + yield* applyKimiAcpModelSelection({ + runtime, + model: "kimi-k2", + selections: [ + { id: "reasoning", value: "high" }, + { id: "auto-approve", value: true }, + { id: "missing", value: "ignored" }, + { id: "reasoning", value: true }, + { id: "auto-approve", value: "true" }, + ], + }); + + expect(calls).toEqual([ + { type: "model", value: "kimi-k2" }, + { type: "config", id: "reasoning", value: "high" }, + { type: "config", id: "auto-approve", value: true }, + ]); + }), + ); + + it.effect( + "skips stale flat and grouped select values while applying later advertised values", + () => + Effect.gen(function* () { + const calls: Array<{ readonly id: string; readonly value: string | boolean }> = []; + const configOptions: ReadonlyArray = [ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "medium", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + { + id: "region", + name: "Region", + type: "select", + currentValue: "us-east", + options: [ + { + group: "North America", + name: "North America", + options: [ + { value: "us-east", name: "US East" }, + { value: "us-west", name: "US West" }, + ], + }, + ], + }, + ]; + const runtime = { + getConfigOptions: Effect.succeed(configOptions), + setModel: () => Effect.void, + setConfigOption: (id: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ id, value }); + }), + }; + + yield* applyKimiAcpModelSelection({ + runtime, + model: undefined, + selections: [ + { id: "effort", value: "stale" }, + { id: "effort", value: "high" }, + { id: "region", value: "moon-base" }, + { id: "region", value: "us-west" }, + ], + }); + + expect(calls).toEqual([ + { id: "effort", value: "high" }, + { id: "region", value: "us-west" }, + ]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/KimiAcpSupport.ts b/apps/server/src/provider/acp/KimiAcpSupport.ts new file mode 100644 index 000000000000..44ff789c91d6 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.ts @@ -0,0 +1,101 @@ +import { type KimiSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { collectSessionConfigOptionValues } from "./AcpRuntimeModel.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type KimiAcpRuntimeKimiSettings = Pick; + +export interface KimiAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "resumeStrategy" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildKimiAcpSpawnInput( + kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: kimiSettings?.binaryPath || "kimi", + args: [...tokenizeCliArgs(kimiSettings?.launchArgs), "acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +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: "login", + resumeStrategy: "resume-first", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +interface KimiAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +export function applyKimiAcpModelSelection(input: { + readonly runtime: KimiAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; +}): Effect.Effect { + return Effect.gen(function* () { + const model = input.model?.trim(); + if (model) { + yield* input.runtime.setModel(model); + } + + const configOptions = yield* input.runtime.getConfigOptions; + for (const selection of input.selections ?? []) { + const configOption = configOptions.find((option) => option.id === selection.id); + if (!configOption) { + continue; + } + if (configOption.type === "boolean" && typeof selection.value !== "boolean") { + continue; + } + if ( + configOption.type === "select" && + (typeof selection.value !== "string" || + !collectSessionConfigOptionValues(configOption).includes(selection.value)) + ) { + continue; + } + yield* input.runtime.setConfigOption(selection.id, selection.value); + } + }); +} diff --git a/apps/server/src/provider/acp/KimiUserInput.test.ts b/apps/server/src/provider/acp/KimiUserInput.test.ts new file mode 100644 index 000000000000..9b2552315588 --- /dev/null +++ b/apps/server/src/provider/acp/KimiUserInput.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractKimiPermissionQuestions, + extractKimiUserQuestions, + resolveKimiQuestionPermissionOption, +} from "./KimiUserInput.ts"; + +describe("extractKimiUserQuestions", () => { + it("parses Kimi AskUserQuestion input", () => { + expect( + extractKimiUserQuestions({ + questions: [ + { + id: "framework", + header: "Framework", + question: "Which framework should I use?", + options: [ + { label: "React", description: "Use React." }, + { label: "Vue", description: "Use Vue." }, + ], + multi_select: false, + }, + ], + }), + ).toEqual([ + { + id: "framework", + header: "Framework", + question: "Which framework should I use?", + options: [ + { label: "React", description: "Use React." }, + { label: "Vue", description: "Use Vue." }, + ], + multiSelect: false, + }, + ]); + }); + + it("derives stable ids and trims fields", () => { + expect( + extractKimiUserQuestions({ + questions: [ + { + header: " Database ", + question: " Which database? ", + options: [" Postgres ", " SQLite "], + }, + ], + }), + ).toEqual([ + { + id: "kimi-question-1-database", + header: "Database", + question: "Which database?", + options: [ + { label: "Postgres", description: "Postgres" }, + { label: "SQLite", description: "SQLite" }, + ], + multiSelect: false, + }, + ]); + }); + + it.each([ + undefined, + {}, + { questions: [] }, + { questions: [{ header: "X", question: "Choose", options: [{ label: "Only" }] }] }, + { + questions: [ + { header: "X", question: "Choose", options: [{ label: "" }, { label: "Valid" }] }, + ], + }, + { tool: "shell", input: { command: "pwd" } }, + ])("rejects malformed or non-question input %#", (input) => { + expect(extractKimiUserQuestions(input)).toBeUndefined(); + }); +}); + +describe("Kimi ACP question permissions", () => { + const request = { + sessionId: "kimi-session", + toolCall: { + toolCallId: "ask-1", + title: "AskUserQuestion", + content: [ + { + type: "content" as const, + content: { type: "text" as const, text: "Which framework should I use?" }, + }, + ], + }, + options: [ + { optionId: "q0_opt_0", name: "React", kind: "allow_once" as const }, + { optionId: "q0_opt_1", name: "Vue", kind: "allow_once" as const }, + { optionId: "q0_skip", name: "Skip", kind: "reject_once" as const }, + ], + }; + + it("recognizes Kimi Code's permission-based question bridge", () => { + expect(extractKimiPermissionQuestions(request)).toEqual([ + { + id: "ask-1", + header: "Question", + question: "Which framework should I use?", + options: [ + { label: "React", description: "React" }, + { label: "Vue", description: "Vue" }, + ], + multiSelect: false, + }, + ]); + }); + + it("matches Kimi's single-choice ACP bridge for legacy raw question input", () => { + expect( + extractKimiPermissionQuestions({ + ...request, + toolCall: { + ...request.toolCall, + rawInput: { + questions: [ + { + id: "framework", + header: "Framework", + question: "Which framework?", + options: ["React", "Vue"], + multiSelect: true, + }, + { + id: "database", + header: "Database", + question: "Which database?", + options: ["Postgres", "SQLite"], + }, + ], + }, + }, + }), + ).toEqual([ + { + id: "framework", + header: "Framework", + question: "Which framework?", + options: [ + { label: "React", description: "React" }, + { label: "Vue", description: "Vue" }, + ], + multiSelect: false, + }, + ]); + }); + + it("round-trips the selected label to Kimi's opaque ACP option id", () => { + const questions = extractKimiPermissionQuestions(request) ?? []; + expect( + resolveKimiQuestionPermissionOption({ + request, + questions, + answers: { "ask-1": "Vue" }, + }), + ).toBe("q0_opt_1"); + }); + + it.each([{ "ask-1": ["Vue"] }, { "ask-1": { answers: ["Vue"] } }, { "ask-1": " Vue " }])( + "accepts supported answer payload shapes %#", + (answers) => { + const questions = extractKimiPermissionQuestions(request) ?? []; + expect(resolveKimiQuestionPermissionOption({ request, questions, answers })).toBe("q0_opt_1"); + }, + ); + + it("trims permission option names before matching", () => { + const spacedRequest = { + ...request, + options: request.options.map((entry) => + entry.optionId === "q0_opt_1" ? { ...entry, name: " Vue " } : entry, + ), + }; + const questions = extractKimiPermissionQuestions(spacedRequest) ?? []; + expect( + resolveKimiQuestionPermissionOption({ + request: spacedRequest, + questions, + answers: { "ask-1": "Vue" }, + }), + ).toBe("q0_opt_1"); + }); +}); diff --git a/apps/server/src/provider/acp/KimiUserInput.ts b/apps/server/src/provider/acp/KimiUserInput.ts new file mode 100644 index 000000000000..1b2ee95e4b63 --- /dev/null +++ b/apps/server/src/provider/acp/KimiUserInput.ts @@ -0,0 +1,138 @@ +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function option(value: unknown): UserInputQuestion["options"][number] | undefined { + if (typeof value === "string") { + const label = text(value); + return label ? { label, description: label } : undefined; + } + if (!isRecord(value)) return undefined; + const label = text(value.label); + if (!label) return undefined; + return { label, description: text(value.description) ?? label }; +} + +function stableQuestionId(index: number, header: string): string { + const slug = header + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return `kimi-question-${index + 1}${slug ? `-${slug}` : ""}`; +} + +export function extractKimiUserQuestions( + input: unknown, +): ReadonlyArray | undefined { + if (!isRecord(input) || !Array.isArray(input.questions) || input.questions.length === 0) { + return undefined; + } + + const questions: UserInputQuestion[] = []; + for (const [index, value] of input.questions.entries()) { + if (!isRecord(value) || !Array.isArray(value.options)) return undefined; + const header = text(value.header); + const question = text(value.question); + if (!header || !question) return undefined; + const options = value.options.map(option); + if (options.length < 2 || options.some((entry) => entry === undefined)) return undefined; + + questions.push({ + id: text(value.id) ?? stableQuestionId(index, header), + header, + question, + options: options as Array, + multiSelect: value.multi_select === true || value.multiSelect === true, + }); + } + + return questions; +} + +function toolCallQuestionText( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + for (const entry of request.toolCall.content ?? []) { + if (entry.type === "content" && entry.content.type === "text") { + const value = text(entry.content.text); + if (value) return value; + } + } + return undefined; +} + +/** + * Kimi Code bridges AskUserQuestion through ACP's permission request surface. + * Prefer its raw tool input when present, while also supporting the current + * upstream bridge that exposes only a title, question text, and named options. + */ +export function extractKimiPermissionQuestions( + request: EffectAcpSchema.RequestPermissionRequest, +): ReadonlyArray | undefined { + if (request.toolCall.title?.trim().toLowerCase() !== "askuserquestion") { + return undefined; + } + + const rawQuestions = extractKimiUserQuestions(request.toolCall.rawInput); + if (rawQuestions) { + // Kimi's ACP bridge can return only one permission option, so mirror the + // CLI's first-question, single-select behavior for legacy raw input. + return [{ ...rawQuestions[0]!, multiSelect: false }]; + } + + const question = toolCallQuestionText(request); + const options = request.options.flatMap((entry) => { + if (entry.kind !== "allow_once") return []; + const label = text(entry.name); + return label ? [{ label, description: label }] : []; + }); + if (!question || options.length < 2) return undefined; + + return [ + { + id: text(request.toolCall.toolCallId) ?? "kimi-question-1-question", + header: "Question", + question, + options, + multiSelect: false, + }, + ]; +} + +function selectedLabels(value: unknown): ReadonlyArray { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) + return value.filter((entry): entry is string => typeof entry === "string"); + if (isRecord(value) && Array.isArray(value.answers)) { + return value.answers.filter((entry): entry is string => typeof entry === "string"); + } + return []; +} + +export function resolveKimiQuestionPermissionOption(input: { + readonly request: EffectAcpSchema.RequestPermissionRequest; + readonly questions: ReadonlyArray; + readonly answers: ProviderUserInputAnswers; +}): string | undefined { + const labels = input.questions.flatMap((question) => + selectedLabels(input.answers[question.id] ?? input.answers[question.question]), + ); + for (const label of labels) { + const normalizedLabel = text(label); + if (!normalizedLabel) continue; + const option = input.request.options.find( + (entry) => entry.kind === "allow_once" && text(entry.name) === normalizedLabel, + ); + if (option?.optionId.trim()) return option.optionId.trim(); + } + return undefined; +} 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.effect("uses the Kimi ACP runtime to generate and decode a commit message", () => + Effect.gen(function* () { + const textGeneration = yield* makeKimiTextGeneration( + decodeKimiSettings({ + binaryPath: process.execPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI launch argument is a fixture path. + launchArgs: JSON.stringify(mockAgentPath), + }), + { + ...process.env, + // @effect-diagnostics-next-line preferSchemaOverJson:off - ACP fixture response must be encoded as text. + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add Kimi provider", + body: "Register the Kimi ACP text-generation path.", + }), + }, + ); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/kimi", + stagedSummary: "M apps/server/src/provider/Drivers/KimiDriver.ts", + stagedPatch: "diff --git a/.../KimiDriver.ts b/.../KimiDriver.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("kimi"), "default"), + }); + + expect(generated).toEqual({ + subject: "Add Kimi provider", + body: "Register the Kimi ACP text-generation path.", + }); + }), + ); +}); diff --git a/apps/server/src/textGeneration/KimiTextGeneration.ts b/apps/server/src/textGeneration/KimiTextGeneration.ts new file mode 100644 index 000000000000..e1227ada7e67 --- /dev/null +++ b/apps/server/src/textGeneration/KimiTextGeneration.ts @@ -0,0 +1,226 @@ +import { TextGenerationError, type KimiSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +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 { applyKimiAcpModelSelection, makeKimiAcpRuntime } from "../provider/acp/KimiAcpSupport.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.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 childProcessSpawner = 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 output = yield* Ref.make(""); + const runtime = yield* makeKimiAcpRuntime({ + kimiSettings, + environment, + childProcessSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") return Effect.void; + const content = update.content; + if (content.type !== "text") return Effect.void; + return Ref.update(output, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* applyKimiAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kimi ACP model or configuration 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: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ operation, detail: "Kimi ACP request failed.", cause }), + ), + ); + const rawResult = (yield* Ref.get(output)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Kimi ACP request was cancelled." + : "Kimi ACP returned empty output.", + }); + } + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kimi ACP 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/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..35c7f03180c3 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "kimi" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..9b21580a35d2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,17 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const KimiIcon: Icon = ({ className, ...props }) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts new file mode 100644 index 000000000000..6b954464fbf8 --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -0,0 +1,17 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { KimiIcon } from "../Icons"; +import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; + +describe("Kimi provider picker metadata", () => { + it("makes Kimi selectable with the Kimi icon", () => { + expect(AVAILABLE_PROVIDER_OPTIONS).toContainEqual({ + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + available: true, + pickerSidebarBadge: "new", + }); + expect(PROVIDER_ICON_BY_PROVIDER[ProviderDriverKind.make("kimi")]).toBe(KimiIcon); + }); +}); 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/providerDriverMeta.test.ts b/apps/web/src/components/settings/providerDriverMeta.test.ts new file mode 100644 index 000000000000..4f48c37d039b --- /dev/null +++ b/apps/web/src/components/settings/providerDriverMeta.test.ts @@ -0,0 +1,15 @@ +import { KimiSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getDriverOption } from "./providerDriverMeta"; + +describe("providerDriverMeta", () => { + it("exposes Kimi as an Early Access provider with its settings schema", () => { + expect(getDriverOption(ProviderDriverKind.make("kimi"))).toMatchObject({ + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + badgeLabel: "Early Access", + settingsSchema: KimiSettings, + }); + }); +}); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..5e68f9ac93d2 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + KimiSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + KimiIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + icon: KimiIcon, + badgeLabel: "Early Access", + settingsSchema: KimiSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..28e2492b4d7e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..62c00933caff 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, Kimi, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..55bd7bcf8cd3 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ │ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ Kimi, OpenCode │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six built-in drivers ship, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, Kimi, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..804a0b9e842d 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread. | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `kimi` | [`Drivers/KimiDriver.ts`][kimi] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an @@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[kimi]: ../../apps/server/src/provider/Drivers/KimiDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts diff --git a/docs/superpowers/plans/2026-08-11-kimi-provider.md b/docs/superpowers/plans/2026-08-11-kimi-provider.md new file mode 100644 index 000000000000..0ebd79c46691 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-kimi-provider.md @@ -0,0 +1,914 @@ +# Kimi Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Kimi Code CLI as a fully integrated Early Access provider across T3 Code's server, web, desktop, and mobile surfaces using the official `kimi acp` protocol. + +**Architecture:** A dedicated `kimi` driver owns Kimi configuration, status, ACP sessions, and auxiliary text generation while reusing T3's shared ACP transport and canonical event helpers. Small opt-in ACP runtime extensions add resume-first continuation and available-command state without changing Cursor or Grok behavior. Clients remain provider-generic apart from presentation metadata and icons. + +**Tech Stack:** TypeScript, Effect, Effect Schema, `effect-acp`, Vitest through `vp test run`, React/Vite, React Native, Astro documentation/marketing. + +## Global Constraints + +- Use driver kind `kimi`, default instance ID `kimi`, product label `Kimi`, and diagnostic label `Kimi Code CLI`. +- Launch only the official ACP transport: ` acp`. +- `KimiSettings.enabled` defaults to `false`; Kimi carries `badgeLabel: "Early Access"`. +- Negotiate capabilities from ACP responses; do not invent model slugs, option values, or supported input types. +- Keep Cursor and Grok behavior unchanged; shared ACP additions must be opt-in or backward compatible. +- Reuse T3's existing per-thread MCP bridge. Do not add a new global MCP configuration product. +- Preserve provider-instance isolation and remote operation: all Kimi processes run on the T3 server. +- Add no new runtime dependency unless an existing repository utility cannot satisfy a required behavior. +- Follow red-green-refactor for every production behavior and run only focused tests/type checks. +- Do not launch browsers, simulators, or other computer-use verification without explicit user approval. + +--- + +### Task 1: Contract, settings, and provider naming + +**Files:** + +- Modify: `packages/contracts/src/settings.ts` +- Modify: `packages/contracts/src/settings.test.ts` +- Modify: `packages/contracts/src/model.ts` +- Modify: `packages/contracts/src/model.test.ts` +- Modify: `apps/server/src/textGeneration/TextGeneration.ts` + +**Interfaces:** + +- Produces: `KimiSettings` with `{ enabled, binaryPath, homePath, launchArgs, customModels }`. +- Produces: legacy `ServerSettings.providers.kimi` and `ServerSettingsPatch.providers.kimi` compatibility fields. +- Produces: `PROVIDER_DISPLAY_NAMES[ProviderDriverKind.make("kimi")] === "Kimi"`. +- Preserves: no hard-coded Kimi default model; live ACP discovery supplies the default. + +- [ ] **Step 1: Write failing contract tests for Kimi defaults and patch decoding** + +Add assertions equivalent to: + +```ts +const settings = Schema.decodeSync(ServerSettings)({ providers: { kimi: {} } }); +expect(settings.providers.kimi).toEqual({ + enabled: false, + binaryPath: "kimi", + homePath: "", + launchArgs: "", + customModels: [], +}); + +const patch = Schema.decodeSync(ServerSettingsPatch)({ + providers: { kimi: { homePath: " ~/.kimi-work ", launchArgs: " --agent coder " } }, +}); +expect(patch.providers?.kimi).toEqual({ + homePath: "~/.kimi-work", + launchArgs: "--agent coder", +}); +``` + +Add a model test asserting `PROVIDER_DISPLAY_NAMES[ProviderDriverKind.make("kimi")]` is `Kimi` and that normalizing an unknown Kimi model preserves its discovered slug. + +- [ ] **Step 2: Run the contract tests and verify the expected failures** + +Run: + +```text +vp test run packages/contracts/src/settings.test.ts packages/contracts/src/model.test.ts +``` + +Expected: failures because `KimiSettings`, `providers.kimi`, and the Kimi display name do not exist. + +- [ ] **Step 3: Add `KimiSettings` and wire both full and patch schemas** + +Add the schema in the built-in provider order before OpenCode: + +```ts +export const KimiSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kimi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kimi Code CLI binary used by this instance.", + providerSettingsForm: { placeholder: "kimi", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "KIMI_CODE_HOME path", + description: "Custom Kimi Code home, configuration, credentials, and sessions directory.", + providerSettingsForm: { placeholder: "~/.kimi-code", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional global CLI arguments passed before kimi acp on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath", "homePath", "launchArgs"] }, +); +export type KimiSettings = typeof KimiSettings.Type; +``` + +Add `kimi` to `ServerSettings.providers`, define `KimiSettingsPatch`, and add it to `ServerSettingsPatch.providers`. + +- [ ] **Step 4: Add Kimi to shared display metadata and text-generation provider typing** + +Add: + +```ts +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); + +export const PROVIDER_DISPLAY_NAMES = { + // existing entries + [KIMI_DRIVER_KIND]: "Kimi", +} satisfies Partial>; +``` + +Extend `TextGenerationProvider` with `"kimi"`. Do not add Kimi to `DEFAULT_MODEL_BY_PROVIDER` or `DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER`; provider snapshots mark the ACP current model as default. + +- [ ] **Step 5: Run focused tests and commit** + +Run: + +```text +vp test run packages/contracts/src/settings.test.ts packages/contracts/src/model.test.ts +``` + +Expected: all selected tests pass. + +Commit: + +```text +git add packages/contracts/src/settings.ts packages/contracts/src/settings.test.ts packages/contracts/src/model.ts packages/contracts/src/model.test.ts apps/server/src/textGeneration/TextGeneration.ts +git commit -m "feat(contracts): add Kimi provider settings" +``` + +### Task 2: Kimi home, process environment, skills, and ACP launch + +**Files:** + +- Create: `apps/server/src/provider/Drivers/KimiHome.ts` +- Create: `apps/server/src/provider/Drivers/KimiHome.test.ts` +- Create: `apps/server/src/provider/Drivers/KimiSkills.ts` +- Create: `apps/server/src/provider/Drivers/KimiSkills.test.ts` +- Create: `apps/server/src/provider/acp/KimiAcpSupport.ts` +- Create: `apps/server/src/provider/acp/KimiAcpSupport.test.ts` + +**Interfaces:** + +- Produces: `resolveKimiHomePath(config): Effect`. +- Produces: `makeKimiEnvironment(config, baseEnv): Effect`. +- Produces: `makeKimiContinuationGroupKey(config): Effect`. +- Produces: `discoverKimiSkills(config, cwd, environment?): Effect, never, FileSystem | Path>`. +- Produces: `buildKimiAcpSpawnInput(settings, cwd, environment): AcpSpawnInput`. +- Produces: `makeKimiAcpRuntime(input)` and `applyKimiAcpModelSelection(input)`. + +- [ ] **Step 1: Write failing home/environment tests** + +Test empty and explicit homes: + +```ts +expect(yield * resolveKimiHomePath({ homePath: "" })).toBe( + path.resolve(NodeOS.homedir(), ".kimi-code"), +); +expect(yield * resolveKimiHomePath({ homePath: "~/.kimi-work" })).toBe( + path.resolve(NodeOS.homedir(), ".kimi-work"), +); +expect( + (yield * makeKimiEnvironment({ homePath: "~/.kimi-work" }, { PATH: "bin" })).KIMI_CODE_HOME, +).toBe(path.resolve(NodeOS.homedir(), ".kimi-work")); +expect(yield * makeKimiContinuationGroupKey({ homePath: "~/.kimi-work" })).toBe( + `kimi:home:${path.resolve(NodeOS.homedir(), ".kimi-work")}`, +); +``` + +- [ ] **Step 2: Run the home tests to verify they fail, then implement the helpers** + +Run `vp test run apps/server/src/provider/Drivers/KimiHome.test.ts`. + +Implement with `expandHomePath`, `node:os.homedir`, and `Path.resolve`. `makeKimiEnvironment` must preserve the supplied base environment and set an absolute `KIMI_CODE_HOME` only when `homePath` is non-empty; the resolved default is used for discovery and continuation identity, not forced into every child environment. + +- [ ] **Step 3: Write failing ACP spawn/model tests** + +Cover binary, argument order, env, auth method, and option ordering: + +```ts +expect( + buildKimiAcpSpawnInput( + { binaryPath: "/opt/kimi", launchArgs: "--agent coder --skills-dir 'team skills'" }, + "/repo", + { KIMI_CODE_HOME: "/homes/work" }, + ), +).toEqual({ + command: "/opt/kimi", + args: ["--agent", "coder", "--skills-dir", "team skills", "acp"], + cwd: "/repo", + env: { KIMI_CODE_HOME: "/homes/work" }, +}); +``` + +Use a fake runtime to assert `applyKimiAcpModelSelection` calls `setModel` first and then only applies stored option selections that still exist in `getConfigOptions`. + +- [ ] **Step 4: Implement Kimi ACP support** + +Use `tokenizeCliArgs` from `@t3tools/shared/cliArgs` and configure the shared runtime with: + +```ts +AcpSessionRuntime.layer({ + ...input, + spawn: buildKimiAcpSpawnInput(input.kimiSettings, input.cwd, input.environment), + authMethodId: "login", + resumeStrategy: "resume-first", +}); +``` + +`applyKimiAcpModelSelection` calls `runtime.setModel(model)` when a non-empty model is requested, reloads `runtime.getConfigOptions`, and calls `setConfigOption(id, value)` only for exact advertised IDs and compatible value types. + +- [ ] **Step 5: Write failing Kimi skill discovery tests** + +Create temporary user/project trees and assert priority: + +```text +/skills/review/SKILL.md +/.agents/skills/shared/SKILL.md +/.kimi-code/skills/review/SKILL.md +/.agents/skills/project/SKILL.md +``` + +Expect project `review` to replace user `review`, valid frontmatter to populate name/display/description, unreadable or malformed entries to be skipped, and results to be sorted by name. + +- [ ] **Step 6: Implement skill discovery and run the slice** + +Parse the same minimal frontmatter fields used by `ClaudeSkills.ts`. Scan configured Kimi home, OS-level `.agents/skills`, and both project directories. Only include a directory containing `SKILL.md`; never follow a discovered path outside its configured root. + +Run: + +```text +vp test run apps/server/src/provider/Drivers/KimiHome.test.ts apps/server/src/provider/Drivers/KimiSkills.test.ts apps/server/src/provider/acp/KimiAcpSupport.test.ts +``` + +Commit: + +```text +git add apps/server/src/provider/Drivers/KimiHome.ts apps/server/src/provider/Drivers/KimiHome.test.ts apps/server/src/provider/Drivers/KimiSkills.ts apps/server/src/provider/Drivers/KimiSkills.test.ts apps/server/src/provider/acp/KimiAcpSupport.ts apps/server/src/provider/acp/KimiAcpSupport.test.ts +git commit -m "feat(server): add Kimi ACP launch support" +``` + +### Task 3: Shared ACP resume and available-command state + +**Files:** + +- Modify: `apps/server/src/provider/acp/AcpRuntimeModel.ts` +- Modify: `apps/server/src/provider/acp/AcpRuntimeModel.test.ts` +- Modify: `apps/server/src/provider/acp/AcpSessionRuntime.ts` +- Modify: `apps/server/src/provider/acp/AcpSessionRuntime.test.ts` + +**Interfaces:** + +- Produces: `AcpParsedSessionEvent` variant `{ _tag: "AvailableCommandsChanged"; commands }`. +- Produces: `AcpSessionRuntime.getAvailableCommands`. +- Produces: optional `AcpSessionRuntimeOptions.resumeStrategy: "load" | "resume-first"`, defaulting to `"load"`. +- Preserves: existing Cursor/Grok startup behavior and tests. + +- [ ] **Step 1: Write a failing parser test for `available_commands_update`** + +Use the generated ACP shape: + +```ts +const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "skill:review", description: "Review the current change", input: { hint: "scope" } }, + ], + }, +}); +expect(parsed.events).toEqual([ + { + _tag: "AvailableCommandsChanged", + commands: [ + { name: "skill:review", description: "Review the current change", input: { hint: "scope" } }, + ], + }, +]); +``` + +- [ ] **Step 2: Implement parsing and retained command state** + +Add the event variant, parse trimmed non-empty commands, and maintain a `Ref>` in `AcpSessionRuntime`. When event ingestion sees `AvailableCommandsChanged`, replace the ref and still enqueue the event. Expose `getAvailableCommands: Ref.get(availableCommandsRef)`. + +- [ ] **Step 3: Write failing resume-first runtime tests** + +Extend the ACP mock peer to record method order. Assert: + +```ts +expect(methods).toEqual(["initialize", "authenticate", "session/resume"]); +``` + +for `resumeStrategy: "resume-first"`, and assert a `methodNotFound` response produces: + +```ts +expect(methods).toEqual(["initialize", "authenticate", "session/resume", "session/load"]); +``` + +Also retain the existing default assertion that Cursor/Grok-style options call only `session/load`. + +- [ ] **Step 4: Implement opt-in resume-first continuation** + +Add a helper inside `startOnce`: + +```ts +const resumeExistingSession = + options.resumeStrategy === "resume-first" + ? runResumeThenFallbackToLoad(options.resumeSessionId) + : runLoadSession(options.resumeSessionId); +``` + +Fallback only for ACP method-not-found/unsupported errors. Authentication, invalid-session, timeout, and transport errors must propagate unchanged. Update mode/config state from either response. + +- [ ] **Step 5: Run shared ACP regression tests and commit** + +Run: + +```text +vp test run apps/server/src/provider/acp/AcpRuntimeModel.test.ts apps/server/src/provider/acp/AcpSessionRuntime.test.ts apps/server/src/provider/acp/CursorAcpSupport.test.ts apps/server/src/provider/acp/GrokAcpSupport.test.ts +``` + +Commit: + +```text +git add apps/server/src/provider/acp/AcpRuntimeModel.ts apps/server/src/provider/acp/AcpRuntimeModel.test.ts apps/server/src/provider/acp/AcpSessionRuntime.ts apps/server/src/provider/acp/AcpSessionRuntime.test.ts +git commit -m "feat(server): retain ACP commands and resume sessions" +``` + +### Task 4: Kimi provider health, model/options discovery, and commands + +**Files:** + +- Create: `apps/server/src/provider/Layers/KimiProvider.ts` +- Create: `apps/server/src/provider/Layers/KimiProvider.test.ts` +- Create: `apps/server/src/provider/acp/KimiAcpCliProbe.test.ts` + +**Interfaces:** + +- Produces: `buildInitialKimiProviderSnapshot(settings)`. +- Produces: `checkKimiProviderStatus(settings, environment, cwd?)`. +- Produces: `enrichKimiSnapshot(input)`. +- Produces: `kimiModelCapabilitiesFromConfigOptions(configOptions)`. +- Produces: ready snapshots with `badgeLabel: "Early Access"`, dynamically discovered models, options, slash commands, and skills. + +- [ ] **Step 1: Write failing snapshot-state tests** + +Cover all status branches with deterministic child-process fixtures: + +```ts +expect((yield * buildInitialKimiProviderSnapshot(disabled)).status).toBe("disabled"); +expect(missing.installed).toBe(false); +expect(missing.message).toContain("not installed"); +expect(unsupported.message).toContain("ACP"); +expect(unauthenticated.auth.status).toBe("unauthenticated"); +expect(unauthenticated.message).toContain("kimi login"); +expect(ready.badgeLabel).toBe("Early Access"); +``` + +The ready fixture must advertise two models, a `thinking` option, plan/default modes, and two commands; assert the current model is `isDefault: true` and duplicate custom models are removed. + +- [ ] **Step 2: Run the provider test and verify missing implementation failures** + +Run `vp test run apps/server/src/provider/Layers/KimiProvider.test.ts`. + +- [ ] **Step 3: Implement initial/version/auth snapshots** + +Use `buildServerProvider`, `spawnAndCollect`, `parseGenericCliVersion`, and `isCommandMissingCause`. Presentation is: + +```ts +const KIMI_PRESENTATION = { + displayName: "Kimi", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; +``` + +Run `kimi --version` with a 4-second timeout. Classify ACP auth-required errors separately from unsupported method/protocol and general startup failures; do not report all failures as missing binaries. + +- [ ] **Step 4: Implement ACP discovery without sending a model prompt** + +Create a short-lived runtime, register a session-update handler, call `start`, drain events, and collect: + +```ts +{ + currentModelId: started.sessionSetupResult.models?.currentModelId, + availableModels: started.sessionSetupResult.models?.availableModels ?? [], + configOptions: yield* runtime.getConfigOptions, + commands: yield* runtime.getAvailableCommands, +} +``` + +Build provider option descriptors from advertised `select` and `boolean` config options except the base model and mode selectors. Preserve exact Kimi option IDs so stored selections can be applied later. Mark the ACP current model as default and merge custom models through `providerModelsFromSettings`. + +- [ ] **Step 5: Add an opt-in live, non-billing CLI probe** + +Create `KimiAcpCliProbe.test.ts` using the `it.live`/environment-gated convention from the Grok/Cursor probes. It may run `kimi --version`, initialize ACP, authenticate, and create/close a throwaway session, but must never call `session/prompt`. + +- [ ] **Step 6: Run provider/probe unit tests and commit** + +Run: + +```text +vp test run apps/server/src/provider/Layers/KimiProvider.test.ts apps/server/src/provider/acp/KimiAcpCliProbe.test.ts +``` + +The live case may be skipped by default; all deterministic cases must pass. + +Commit: + +```text +git add apps/server/src/provider/Layers/KimiProvider.ts apps/server/src/provider/Layers/KimiProvider.test.ts apps/server/src/provider/acp/KimiAcpCliProbe.test.ts +git commit -m "feat(server): probe Kimi models and capabilities" +``` + +### Task 5: Kimi adapter session and turn lifecycle + +**Files:** + +- Create: `apps/server/src/provider/Services/KimiAdapter.ts` +- Create: `apps/server/src/provider/Layers/KimiAdapter.ts` +- Create: `apps/server/src/provider/Layers/KimiAdapter.test.ts` +- Create: `apps/server/src/provider/testFixtures/kimiAcpMockPeer.mjs` + +**Interfaces:** + +- Produces: `KimiAdapterShape extends ProviderAdapterShape`. +- Produces: `makeKimiAdapter(settings, options)` implementing every `ProviderAdapterShape` method. +- Produces: resume cursor `{ schemaVersion: 1, sessionId: string }`. +- Consumes: `makeKimiAcpRuntime`, canonical ACP event factories, attachment store, and `McpProviderSession`. + +- [ ] **Step 1: Build a deterministic Kimi ACP mock peer** + +The peer must implement newline-delimited ACP JSON-RPC for initialize/authenticate/new/resume/load/prompt/cancel/set-config-option. Scenario flags supplied through environment variables make it emit assistant chunks, plans, tools, permission requests, commands, config updates, delayed prompt completion, and process exit. It writes received method/payload records to a test-owned path for assertions. + +- [ ] **Step 2: Write failing adapter tests for start, prompt, events, resume, and stop** + +Cover: + +```ts +const session = + yield * + adapter.startSession({ + threadId, + cwd, + modelSelection: { instanceId: ProviderInstanceId.make("kimi"), model: "kimi-code/k3" }, + runtimeMode: "approval-required", + }); +expect(session.provider).toBe(ProviderDriverKind.make("kimi")); +expect(session.resumeCursor).toEqual({ schemaVersion: 1, sessionId: "kimi-session-1" }); + +const turn = yield * adapter.sendTurn({ threadId, input: "Inspect this repository" }); +expect(turn.threadId).toBe(threadId); +``` + +Collect `streamEvents` and assert assistant item start/delta/completion, tool lifecycle, plan update, and turn completion all carry the bound Kimi instance ID. Start a second adapter instance and prove events/sessions do not cross instances. Resume must call `session/resume`; stop must close only the owned peer process. + +- [ ] **Step 3: Run the adapter test and verify it fails because the adapter is absent** + +Run `vp test run apps/server/src/provider/Layers/KimiAdapter.test.ts`. + +- [ ] **Step 4: Implement session context, locking, startup, and cleanup** + +Use a per-thread session context containing: + +```ts +interface KimiSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + session: ProviderSession; + activeTurnId: TurnId | undefined; + promptsInFlight: number; + interruptedTurnIds: Set; + turns: Array<{ id: TurnId; items: Array }>; + stopped: boolean; +} +``` + +Add per-thread semaphores, instance-local PubSub, deterministic finalizers, versioned resume parsing, and MCP session cleanup. `rollbackThread` validates its input and returns the explicit error `Kimi ACP sessions do not support provider-side rollback.` + +- [ ] **Step 5: Implement prompt conversion and canonical event ingestion** + +Convert input and attachments based on initialized prompt capabilities. Text is always a text block; images become base64 ACP images only when advertised; text resources become resource blocks when advertised; unsupported media becomes a text block containing the resolved server-local file path. Pass the existing T3 MCP bridge returned by `McpProviderSession` in session startup. + +Translate `ContentDelta`, `PlanUpdated`, `ToolCallUpdated`, assistant segment events, and prompt settlement through the shared canonical factories. Serialize prompt settlement with stop/steer operations and reject late events whose ACP session or active turn no longer matches. + +- [ ] **Step 6: Implement interrupt/read/list/stop methods and verify core lifecycle** + +`interruptTurn` marks the target before calling ACP cancel; `stopSession` cancels pending work, closes the child scope, removes the map entry, and publishes a closed session event. `stopAll` iterates only this adapter's map. `listSessions`, `hasSession`, and `readThread` return snapshots, never mutable references. + +Run: + +```text +vp test run apps/server/src/provider/Layers/KimiAdapter.test.ts apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +``` + +Commit: + +```text +git add apps/server/src/provider/Services/KimiAdapter.ts apps/server/src/provider/Layers/KimiAdapter.ts apps/server/src/provider/Layers/KimiAdapter.test.ts apps/server/src/provider/testFixtures/kimiAcpMockPeer.mjs +git commit -m "feat(server): add Kimi ACP sessions" +``` + +### Task 6: Kimi permissions, questions, modes, steering, and option changes + +**Files:** + +- Modify: `apps/server/src/provider/Layers/KimiAdapter.ts` +- Modify: `apps/server/src/provider/Layers/KimiAdapter.test.ts` +- Create: `apps/server/src/provider/acp/KimiUserInput.ts` +- Create: `apps/server/src/provider/acp/KimiUserInput.test.ts` + +**Interfaces:** + +- Produces: `extractKimiUserQuestions(request): ReadonlyArray | undefined`. +- Produces: exact runtime-mode permission policy at the adapter boundary. +- Produces: plan/default mode resolution from advertised ACP modes. +- Preserves: user questions remain interactive in Full access. + +- [ ] **Step 1: Write failing question-shape parser tests** + +Parse Kimi's documented `AskUserQuestion` input: + +```ts +expect(extractKimiUserQuestions(request)).toEqual([ + { + id: "framework", + header: "Framework", + question: "Which framework should I use?", + options: [ + { label: "React", description: "Use React." }, + { label: "Vue", description: "Use Vue." }, + ], + multiSelect: false, + }, +]); +``` + +Reject malformed questions, empty labels, fewer than two valid choices, or arbitrary tool requests. Preserve stable IDs from upstream when present and otherwise derive deterministic IDs from array position/header. + +- [ ] **Step 2: Implement the parser and run its focused test** + +Run `vp test run apps/server/src/provider/acp/KimiUserInput.test.ts`, observe the missing-function failure, implement strict unknown-to-typed parsing, and rerun to green. + +- [ ] **Step 3: Write failing permission-mode tests** + +For the same ACP request stream, assert: + +| T3 mode | File edit | Command | Kimi question | +| ------------------- | ----------------------------------------------------------- | -------------- | ---------------- | +| `approval-required` | opens approval | opens approval | opens user input | +| `auto-accept-edits` | auto allows | opens approval | opens user input | +| `auto` | selects advertised Kimi auto mode; otherwise opens approval | same | opens user input | +| `full-access` | auto allows | auto allows | opens user input | + +Assert `acceptForSession`, `accept`, and `decline` choose permission options by semantic kind, never array position. Missing semantic options return a typed request error. + +- [ ] **Step 4: Implement pending approvals and structured questions** + +Store Deferred entries by canonical request ID. Permission-shaped requests publish `request.opened` and wait for `respondToRequest`. Question-shaped requests publish `user-input.requested`, wait for `respondToUserInput`, convert selected labels/free text to the ACP response option accepted by Kimi, and publish resolution. Interrupt, stop, and process exit resolve both maps as cancelled. + +- [ ] **Step 5: Write failing plan/mode and steering tests** + +Advertise modes `default`, `auto`, and `plan`. Assert plan interaction calls `setMode("plan")`, returning to default calls `setMode("default")`, and `auto` calls `setMode("auto")`. When a mode is absent, assert no fabricated ID is sent. Send a second prompt while the first is active and assert both belong to one T3 turn and only the last in-flight prompt completes it. + +- [ ] **Step 6: Implement mode resolution, steering settlement, and dynamic option updates** + +Resolve modes by exact normalized ID/name first, then conservative aliases. Apply model, options, and mode before each prompt. Refresh config options after model mutation and drop stale selections with a bounded warning. Maintain `promptsInFlight`, active session identity, and interrupted-turn checks through final settlement. + +- [ ] **Step 7: Run the complete adapter slice and commit** + +Run: + +```text +vp test run apps/server/src/provider/acp/KimiUserInput.test.ts apps/server/src/provider/Layers/KimiAdapter.test.ts +``` + +Commit: + +```text +git add apps/server/src/provider/acp/KimiUserInput.ts apps/server/src/provider/acp/KimiUserInput.test.ts apps/server/src/provider/Layers/KimiAdapter.ts apps/server/src/provider/Layers/KimiAdapter.test.ts +git commit -m "feat(server): support Kimi interactions and modes" +``` + +### Task 7: Kimi auxiliary generation and built-in driver registration + +**Files:** + +- Create: `apps/server/src/textGeneration/KimiTextGeneration.ts` +- Create: `apps/server/src/textGeneration/KimiTextGeneration.test.ts` +- Create: `apps/server/src/provider/Drivers/KimiDriver.ts` +- Create: `apps/server/src/provider/Drivers/KimiDriver.test.ts` +- Modify: `apps/server/src/provider/builtInDrivers.ts` +- Create: `apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts` +- Modify: `apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts` + +**Interfaces:** + +- Produces: all four `TextGeneration` methods backed by a scoped Kimi ACP session. +- Produces: `KimiDriver: ProviderDriver`. +- Registers: Kimi in `BUILT_IN_DRIVERS` and `BuiltInDriversEnv`. + +- [ ] **Step 1: Write failing structured text-generation tests** + +Use the mock peer to return JSON for thread title, branch, commit, and change request. Assert selected model application, sanitization, invalid JSON, empty response, cancelled response, timeout, and process cleanup. A representative assertion is: + +```ts +expect( + yield * + textGeneration.generateThreadTitle({ + cwd, + message: "Add Kimi support", + modelSelection: { instanceId: ProviderInstanceId.make("kimi"), model: "kimi-code/k3" }, + }), +).toEqual({ title: "Add Kimi support" }); +``` + +- [ ] **Step 2: Run the generation test red, then implement `KimiTextGeneration`** + +Run `vp test run apps/server/src/textGeneration/KimiTextGeneration.test.ts`. + +Implement one scoped `runKimiJson` helper using `build*Prompt`, `extractJsonObject`, existing sanitizers, `makeKimiAcpRuntime`, a 180-second timeout, and `Effect.scoped`. It must register assistant update collection before `start`, apply the selected model, send exactly one text prompt, decode the requested schema, and close on every path. + +- [ ] **Step 3: Write failing driver construction tests** + +Assert metadata, defaults, continuation identity, environment, instance stamping, maintenance package, adapter binding, and text-generation binding: + +```ts +expect(KimiDriver.driverKind).toBe(ProviderDriverKind.make("kimi")); +expect(KimiDriver.metadata).toEqual({ displayName: "Kimi", supportsMultipleInstances: true }); +expect(KimiDriver.defaultConfig()).toMatchObject({ enabled: false, binaryPath: "kimi" }); +``` + +- [ ] **Step 4: Implement `KimiDriver` and maintenance resolution** + +Compose `makeKimiEnvironment` after `mergeProviderInstanceEnvironment`, use the resolved home for continuation identity, construct adapter/text generation, and use `makeManagedServerProvider` with the Kimi snapshot functions. Configure package maintenance for `@moonshot-ai/kimi-code`; package-manager-owned paths receive the matching global update command and standalone/native paths remain manual-only. + +- [ ] **Step 5: Register Kimi and prove hydration/instance isolation** + +Import `KimiDriver` in `builtInDrivers.ts`, include `KimiDriverEnv` in the union, and place `KimiDriver` before OpenCode. Create `ProviderInstanceRegistryHydration.test.ts` with a pure test that decodes `{ providers: { kimi: {} } }`, calls `deriveProviderInstanceConfigMap`, and asserts the default `kimi` instance has driver kind `kimi` and the decoded Kimi config. Add a registry-live test with two Kimi instance envelopes that have different homes/environments; assert distinct adapters and continuation keys. + +- [ ] **Step 6: Run focused backend tests and commit** + +Run: + +```text +vp test run apps/server/src/textGeneration/KimiTextGeneration.test.ts apps/server/src/provider/Drivers/KimiDriver.test.ts apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +``` + +Commit: + +```text +git add apps/server/src/textGeneration/KimiTextGeneration.ts apps/server/src/textGeneration/KimiTextGeneration.test.ts apps/server/src/provider/Drivers/KimiDriver.ts apps/server/src/provider/Drivers/KimiDriver.test.ts apps/server/src/provider/builtInDrivers.ts apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +git commit -m "feat(server): register Kimi provider driver" +``` + +### Task 8: Web and desktop presentation + +**Files:** + +- Modify: `apps/web/src/components/Icons.tsx` +- Modify: `apps/web/src/components/settings/providerDriverMeta.ts` +- Create: `apps/web/src/components/settings/providerDriverMeta.test.ts` +- Modify: `apps/web/src/components/chat/providerIconUtils.ts` +- Create: `apps/web/src/components/chat/providerIconUtils.test.ts` +- Modify: `apps/web/src/session-logic.ts` +- Modify: `apps/web/src/session-logic.test.ts` +- Modify: `apps/web/src/lib/contextWindow.test.ts` + +**Interfaces:** + +- Produces: `KimiIcon` and Kimi icon lookup. +- Produces: Kimi provider settings definition with Early Access badge and `KimiSettings` form. +- Produces: available Kimi model-picker entry with `pickerSidebarBadge: "new"`. +- Preserves: generic unknown-provider fallbacks. + +- [ ] **Step 1: Write failing metadata and provider-list tests** + +Assert: + +```ts +expect(getDriverOption(ProviderDriverKind.make("kimi"))).toMatchObject({ + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + badgeLabel: "Early Access", + settingsSchema: KimiSettings, +}); +expect(PROVIDER_OPTIONS).toContainEqual({ + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + available: true, + pickerSidebarBadge: "new", +}); +``` + +Assert the icon map returns `KimiIcon` and `formatProviderDisplayName("kimi")` returns `Kimi`. + +- [ ] **Step 2: Run the web tests and verify missing metadata failures** + +Run: + +```text +vp test run apps/web/src/components/settings/providerDriverMeta.test.ts apps/web/src/components/chat/providerIconUtils.test.ts apps/web/src/session-logic.test.ts apps/web/src/lib/contextWindow.test.ts +``` + +- [ ] **Step 3: Add the Kimi icon and client definition** + +Add a monochrome `KimiIcon` using an official Moonshot/Kimi vector source that permits repository redistribution, following the `Icon` component/viewBox/className conventions. Register: + +```ts +{ + value: ProviderDriverKind.make("kimi"), + label: "Kimi", + icon: KimiIcon, + badgeLabel: "Early Access", + settingsSchema: KimiSettings, +} +``` + +Add it to `PROVIDER_ICON_BY_PROVIDER` and `PROVIDER_OPTIONS`. The desktop wrapper requires no separate code because it renders the web provider settings and starts the same server driver. + +- [ ] **Step 4: Run web tests and commit** + +Run the Step 2 command again. Expected: all selected tests pass. + +Commit: + +```text +git add apps/web/src/components/Icons.tsx apps/web/src/components/settings/providerDriverMeta.ts apps/web/src/components/settings/providerDriverMeta.test.ts apps/web/src/components/chat/providerIconUtils.ts apps/web/src/components/chat/providerIconUtils.test.ts apps/web/src/session-logic.ts apps/web/src/session-logic.test.ts apps/web/src/lib/contextWindow.test.ts +git commit -m "feat(web): expose Kimi provider controls" +``` + +### Task 9: Mobile presentation and Early Access status + +**Files:** + +- Modify: `apps/mobile/src/components/ProviderIcon.tsx` +- Create: `apps/mobile/src/components/ProviderIcon.test.tsx` +- Modify: `apps/mobile/src/lib/modelOptions.ts` +- Modify: `apps/mobile/src/lib/modelOptions.test.ts` +- Modify: `apps/mobile/src/features/threads/ThreadSettingsSheet.tsx` +- Modify: `apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts` + +**Interfaces:** + +- Produces: Kimi icon and canonical label on mobile. +- Produces: Early Access badge text from `ServerProvider.badgeLabel` in the provider group/header where mobile shows provider metadata. +- Preserves: Kimi stays outside `PRIMARY_PROVIDER_DRIVERS` while Early Access, so it appears in the existing secondary provider grouping rather than displacing Codex/Claude. + +- [ ] **Step 1: Write failing mobile label/icon/group tests** + +Add a Kimi provider snapshot to `modelOptions.test.ts`: + +```ts +{ + instanceId: "kimi", + driver: "kimi", + displayName: undefined, + badgeLabel: "Early Access", + models: [{ slug: "kimi-code/k3", name: "Kimi K3", isCustom: false, capabilities: null }], +} +``` + +Assert its group label is `Kimi`, model subtitle is `Kimi`, the provider icon renders the Kimi branch, and the secondary group state retains `Early Access`. + +- [ ] **Step 2: Run mobile tests red, then implement the presentation** + +Run: + +```text +vp test run apps/mobile/src/components/ProviderIcon.test.tsx apps/mobile/src/lib/modelOptions.test.ts apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +``` + +Add an explicit `provider.driver === "kimi"` label, a Kimi SVG branch in `ProviderIcon`, and render the existing snapshot `badgeLabel` adjacent to the provider group title with the same subdued badge style used elsewhere in mobile. Do not add Kimi to `PRIMARY_PROVIDER_DRIVERS` during Early Access. + +- [ ] **Step 3: Rerun mobile tests and commit** + +Run the Step 2 command again. Expected: all selected tests pass. + +Commit: + +```text +git add apps/mobile/src/components/ProviderIcon.tsx apps/mobile/src/components/ProviderIcon.test.tsx apps/mobile/src/lib/modelOptions.ts apps/mobile/src/lib/modelOptions.test.ts apps/mobile/src/features/threads/ThreadSettingsSheet.tsx apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +git commit -m "feat(mobile): show Kimi provider models" +``` + +### Task 10: Orchestration integration, documentation, and complete focused verification + +**Files:** + +- Create: `apps/server/integration/kimiProvider.integration.test.ts` +- Modify: `docs/user/install.md` +- Create: `docs/user/providers-kimi.md` +- Modify: `docs/internals/providers.md` +- Modify: `docs/internals/overview.md` +- Modify: `docs/internals/glossary.md` +- Modify: `apps/marketing/src/pages/index.astro` +- Create: `apps/marketing/public/harnesses/kimi.svg` + +**Interfaces:** + +- Produces: one end-to-end server proof from orchestration command through Kimi canonical events and checkpoint receipt. +- Produces: user installation/login/multi-instance/permissions/troubleshooting documentation. +- Updates: every explicit five-provider list to six providers without implying General Availability. + +- [ ] **Step 1: Write the failing orchestration integration test** + +Use the deterministic Kimi mock peer and existing provider integration harness. Dispatch project/thread creation and `thread.turn.start`, then wait on provider ingestion receipts and worker drains. Assert the final projection contains: + +```ts +expect(snapshot.session?.providerName).toBe("kimi"); +expect(snapshot.session?.providerInstanceId).toBe(ProviderInstanceId.make("kimi")); +expect(snapshot.messages.at(-1)?.text).toBe("Kimi integration response."); +expect(snapshot.latestTurn?.status).toBe("completed"); +expect(snapshot.checkpoints.length).toBeGreaterThan(0); +``` + +Do not use sleeps or polling. + +- [ ] **Step 2: Run the integration test red, wire any missing generic registration, and rerun** + +Run: + +```text +vp test run apps/server/integration/kimiProvider.integration.test.ts +``` + +Fix only missing Kimi registration/event plumbing exposed by this test. Rerun until it passes. + +- [ ] **Step 3: Write shipped-product Kimi documentation** + +`providers-kimi.md` must cover official installation links, `kimi login`, enabling Early Access Kimi, binary path, `KIMI_CODE_HOME`, multiple accounts/homes, environment secrets, model/thinking/plan selection, permission behavior, session continuation, remote-server placement, and upstream ACP limitations. Use user-facing language and omit repository source paths. + +Add Kimi to the install provider table. Update internal built-in driver tables and every sentence that says five built-in providers. Add the Kimi driver/source link in `providers.md`. + +- [ ] **Step 4: Update marketing provider copy without hiding Early Access status** + +Add the official redistributable Kimi SVG asset, a Kimi harness card, and Kimi to enumerated provider copy. Label it `Kimi Code (Early Access)` in the provider card so the marketing surface does not imply GA. + +- [ ] **Step 5: Run the complete focused test set** + +Run: + +```text +vp test run packages/contracts/src/settings.test.ts packages/contracts/src/model.test.ts apps/server/src/provider/Drivers/KimiHome.test.ts apps/server/src/provider/Drivers/KimiSkills.test.ts apps/server/src/provider/acp/AcpRuntimeModel.test.ts apps/server/src/provider/acp/AcpSessionRuntime.test.ts apps/server/src/provider/acp/KimiAcpSupport.test.ts apps/server/src/provider/Layers/KimiProvider.test.ts apps/server/src/provider/Layers/KimiAdapter.test.ts apps/server/src/provider/acp/KimiUserInput.test.ts apps/server/src/textGeneration/KimiTextGeneration.test.ts apps/server/src/provider/Drivers/KimiDriver.test.ts apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts apps/server/integration/kimiProvider.integration.test.ts apps/web/src/components/settings/providerDriverMeta.test.ts apps/web/src/components/chat/providerIconUtils.test.ts apps/web/src/session-logic.test.ts apps/mobile/src/components/ProviderIcon.test.tsx apps/mobile/src/lib/modelOptions.test.ts apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +``` + +Expected: zero failures and no unexpected warnings. + +- [ ] **Step 6: Run targeted type checks, lint, formatting, and asset verification** + +Run: + +```text +vp run --filter @t3tools/contracts typecheck +vp run --filter t3 typecheck +vp run --filter @t3tools/web typecheck +vp run --filter @t3tools/mobile typecheck +vp lint packages/contracts/src/settings.ts packages/contracts/src/model.ts apps/server/src/provider apps/server/src/textGeneration/KimiTextGeneration.ts apps/web/src/components apps/web/src/session-logic.ts apps/mobile/src/components/ProviderIcon.tsx apps/mobile/src/lib/modelOptions.ts apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +vp fmt --check +node scripts/export-brand-icons.ts --check +git diff --check +``` + +Do not replace these targeted filters with a repo-wide check. + +- [ ] **Step 7: Audit every surface and upstream limitation** + +Search: + +```text +rg -n 'five drivers|Codex, Claude, Cursor, Grok|ProviderDriverKind.make\(|PROVIDER_DISPLAY_NAMES|PROVIDER_OPTIONS|PROVIDER_ICON_BY_PROVIDER|PRIMARY_PROVIDER_DRIVERS' apps packages docs +``` + +Confirm web, desktop, mobile, all connection modes, models/options, modes, stop/resume reverse states, text generation, docs, and provider instance settings are either implemented or explicitly documented as upstream ACP limitations. Do not add Kimi to Codex/Claude-only usage transcript lists. + +- [ ] **Step 8: Commit the integration and documentation slice** + +```text +git add apps/server/integration/kimiProvider.integration.test.ts docs/user/install.md docs/user/providers-kimi.md docs/internals/providers.md docs/internals/overview.md docs/internals/glossary.md apps/marketing/src/pages/index.astro apps/marketing/public/harnesses/kimi.svg +git commit -m "docs: document Kimi provider support" +``` + +- [ ] **Step 9: Request explicit approval for real-client verification** + +Report the automated evidence and ask whether to run one integrated web pass with `test-t3-app` and one mobile pass with `test-t3-mobile`. Do not launch either without approval. diff --git a/docs/superpowers/plans/2026-08-12-kimi-model-discovery.md b/docs/superpowers/plans/2026-08-12-kimi-model-discovery.md new file mode 100644 index 000000000000..be37b1986a0f --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-kimi-model-discovery.md @@ -0,0 +1,282 @@ +# Kimi Model Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Populate Kimi models and model-specific thinking controls from the generic ACP config-option response used by Kimi Code CLI 0.29.1. + +**Architecture:** Normalize the model catalog inside `KimiProvider` at the ACP adapter boundary. Prefer the generic `model` select config option, fall back to legacy ACP model state, then reuse the existing bounded per-model capability probe and provider snapshot pipeline. + +**Tech Stack:** TypeScript, Effect, effect-acp schemas, Vite Plus/Vitest + +## Global Constraints + +- Kimi's official `K2.7 Coding Highspeed` entry is a model, not a synthetic speed toggle. +- Do not hard-code the managed Kimi model catalog in production code. +- Preserve older ACP implementations that return `models.availableModels`. +- Do not change contracts, web, mobile, orchestration, or shared ACP runtime behavior. +- Use only focused tests and the targeted server typecheck. +- Run every JavaScript or TypeScript repository command from a fresh login shell where + `node --version` satisfies `package.json`'s `^24.13.1` requirement. The implementation shell was + verified with Node `v24.14.0`; do not copy a machine-specific Node path into repository commands. + +## Provider Adapter Decisions + +| Provider | Decision | Verification | +| -------- | ------------------------------------------ | ------------------------------------------------------------ | +| Kimi | Update generic ACP model-option discovery. | Focused Kimi provider fixture and live ACP probe. | +| Codex | Unchanged. | Final diff check confirms no Codex adapter files changed. | +| Claude | Unchanged. | Final diff check confirms no Claude adapter files changed. | +| Cursor | Unchanged. | Final diff check confirms no Cursor adapter files changed. | +| Grok | Unchanged. | Final diff check confirms no Grok adapter files changed. | +| OpenCode | Unchanged. | Final diff check confirms no OpenCode adapter files changed. | + +--- + +### Task 1: Normalize modern and legacy Kimi model discovery + +**Files:** + +- Modify: `apps/server/src/provider/Layers/KimiProvider.test.ts:25-275` +- Modify: `apps/server/src/provider/Layers/KimiProvider.ts:52-285` + +**Interfaces:** + +- Consumes: `EffectAcpSchema.NewSessionResponse | LoadSessionResponse | ResumeSessionResponse` +- Produces: normalized `{ currentModelId, availableModels }` values used by the existing `KimiAcpDiscovery` +- Preserves: `kimiModelCapabilitiesFromConfigOptions` and `applyKimiAcpModelSelection` option IDs + +- [ ] **Step 1: Make the deterministic fixture match Kimi Code CLI 0.29.1** + +Remove the legacy `models` object from the fixture's `session/new` response. Advertise the model +catalog through the real generic select shape and return model-specific thinking values: + +```ts +const modelOptions = [ + { value: "kimi-code/kimi-for-coding", name: "K2.7 Coding" }, + { + value: "kimi-code/kimi-for-coding-highspeed", + name: "K2.7 Coding Highspeed", + }, + { value: "kimi-code/k3", name: "K3" }, + { value: "kimi-code/k3-256k", name: "K3-256k" }, +]; + +const configOptions = () => [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModel, + options: modelOptions, + }, + currentModel === "kimi-code/k3" || currentModel === "kimi-code/k3-256k" + ? { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + } + : { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "on", + options: [{ value: "on", name: "On" }], + }, + // Existing mode option remains unchanged. +]; +``` + +Change the ready-state assertion to expect all four literal model IDs, the first model as default, +Highspeed as a normal model, and K3's literal `Low | High | Max` capability descriptor with `High` +marked default. + +The production mutation caught by this test is: reading only `sessionSetupResult.models` returns an +empty provider model list for the current Kimi CLI. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +node --version # Must satisfy ^24.13.1 before running pnpm. +pnpm exec vp test run apps/server/src/provider/Layers/KimiProvider.test.ts +``` + +Expected: the ready-state assertion fails because the provider snapshot contains no discovered +models (or only explicitly configured custom models). + +- [ ] **Step 3: Add pure normalization coverage for malformed and legacy inputs** + +Export a narrow helper named `kimiModelStateFromSessionSetup` and add two direct assertions: + +```ts +expect( + kimiModelStateFromSessionSetup({ + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-code/k3", + options: [ + { value: "", name: "Blank" }, + { value: "kimi-code/k3", name: "K3" }, + { value: "kimi-code/k3", name: "Duplicate" }, + ], + }, + ], + }), +).toEqual({ + currentModelId: "kimi-code/k3", + availableModels: [{ modelId: "kimi-code/k3", name: "K3" }], +}); +``` + +The legacy assertion passes a setup object with `models.currentModelId` and +`models.availableModels` but no model config option, and expects those values unchanged. These tests +catch incorrect precedence, blank entries, duplicate entries, and accidental removal of backwards +compatibility. + +- [ ] **Step 4: Run the focused test and confirm both new assertions are RED** + +Run the same focused command. Expected: the helper import/export is missing before production code +is written. + +- [ ] **Step 5: Implement the minimal normalizer and use it in discovery** + +Add a setup-response structural input type and this behavior in `KimiProvider.ts`: + +```ts +export function kimiModelStateFromSessionSetup(setup: { + readonly models?: EffectAcpSchema.SessionModelState | null; + readonly configOptions?: ReadonlyArray | null; +}): Pick { + const modelOption = setup.configOptions?.find( + (option) => option.category === "model" && option.type === "select", + ); + if (modelOption) { + const seen = new Set(); + const availableModels = collectSessionConfigOptionValuesWithNames(modelOption).flatMap( + ({ value, name }) => { + const modelId = value.trim(); + if (!modelId || seen.has(modelId)) return []; + seen.add(modelId); + return [{ modelId, name: name.trim() || modelId }]; + }, + ); + if (availableModels.length > 0) { + return { + currentModelId: modelOption.currentValue?.trim() || undefined, + availableModels, + }; + } + } + return { + currentModelId: setup.models?.currentModelId?.trim() || undefined, + availableModels: setup.models?.availableModels ?? [], + }; +} +``` + +Implement the flattening locally using the same grouped/ungrouped select-option traversal already +used by `kimiModelCapabilitiesFromConfigOptions`; do not add a shared abstraction. In +`discoverKimiViaAcp`, obtain `initialConfigOptions` first, normalize from both setup sources, and +then run the existing per-model capability loop. + +- [ ] **Step 6: Run focused GREEN verification** + +Run: + +```powershell +node --version # Must satisfy ^24.13.1 before running pnpm. +pnpm exec vp test run apps/server/src/provider/Layers/KimiProvider.test.ts +``` + +Expected: all Kimi provider tests pass, including modern ACP discovery, K3 thinking levels, +deduplication, and legacy fallback. + +- [ ] **Step 7: Run the real installed-CLI non-billing probe** + +Run: + +```powershell +node --version # Must satisfy ^24.13.1 before running pnpm. +$env:T3_KIMI_ACP_PROBE='1' +pnpm exec vp test run apps/server/src/provider/acp/KimiAcpCliProbe.test.ts +Remove-Item Env:T3_KIMI_ACP_PROBE +``` + +Expected: the installed Kimi CLI initializes, authenticates, and creates a throwaway ACP session +without sending a prompt. + +- [ ] **Step 8: Run targeted typecheck and diff review** + +Run: + +```powershell +node --version # Must satisfy ^24.13.1 before running pnpm. +pnpm exec vp run --filter t3 typecheck +git diff --check +git diff -- apps/server/src/provider/Layers/KimiProvider.ts apps/server/src/provider/Layers/KimiProvider.test.ts +``` + +Expected: typecheck exits zero, no whitespace errors, and the diff remains confined to discovery +and its focused coverage. + +- [ ] **Step 9: Commit the implementation** + +```powershell +git add apps/server/src/provider/Layers/KimiProvider.ts apps/server/src/provider/Layers/KimiProvider.test.ts +git commit -m "fix(provider): discover current Kimi models" +``` + +--- + +### Task 2: Verify the complete branch before publication + +**Files:** + +- Verify: `docs/superpowers/specs/2026-08-12-kimi-model-discovery-design.md` +- Verify: `docs/superpowers/plans/2026-08-12-kimi-model-discovery.md` +- Verify: `apps/server/src/provider/Layers/KimiProvider.ts` +- Verify: `apps/server/src/provider/Layers/KimiProvider.test.ts` + +**Interfaces:** + +- Consumes: completed Task 1 branch +- Produces: a reviewable, focused branch ready for a non-draft pull request + +- [ ] **Step 1: Run final focused verification from a clean command** + +```powershell +node --version # Must satisfy ^24.13.1 before running pnpm. +pnpm exec vp test run apps/server/src/provider/Layers/KimiProvider.test.ts apps/server/src/provider/acp/KimiAcpCliProbe.test.ts +pnpm exec vp run --filter t3 typecheck +git diff --check fork/main...HEAD +git diff --exit-code fork/main...HEAD -- apps/server/src/provider/Layers/CodexProvider.ts apps/server/src/provider/Layers/ClaudeProvider.ts apps/server/src/provider/Layers/CursorProvider.ts apps/server/src/provider/Layers/GrokProvider.ts apps/server/src/provider/Layers/OpenCodeProvider.ts +git status --short +``` + +Expected: deterministic tests pass; the opt-in live probe remains skipped unless its environment +flag is set; typecheck exits zero; branch contains only the approved spec, plan, implementation, and +tests. + +- [ ] **Step 2: Confirm the branch commits and file scope** + +```powershell +git log --oneline fork/main..HEAD +git diff --stat fork/main...HEAD +``` + +Expected: the branch contains the design, plan, focused implementation, and tests only. It is then +ready for the repository's PR publication and review-babysitting workflow. diff --git a/docs/superpowers/specs/2026-08-11-kimi-provider-design.md b/docs/superpowers/specs/2026-08-11-kimi-provider-design.md new file mode 100644 index 000000000000..5f742a6fba59 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-kimi-provider-design.md @@ -0,0 +1,399 @@ +# Kimi Provider Design + +## Summary + +Add Kimi Code CLI as a first-class T3 Code provider using the official `kimi acp` stdio +interface. The provider uses the driver kind and default instance ID `kimi`, appears as **Kimi** +throughout T3 Code, and initially carries the same **Early Access** badge used by Cursor and Grok. + +Early Access describes rollout maturity, not an intentionally reduced feature set. The integration +targets the full capability surface exposed by the installed Kimi ACP server and degrades honestly +when the CLI does not advertise an optional feature. + +## Goals + +- Make Kimi available from web, desktop, and mobile wherever another provider can be selected or + configured. +- Support the complete T3 thread lifecycle: create, resume, prompt, steer, interrupt, stop, and + recover after server or client reconnects. +- Support streaming assistant output, plans, tool activity, approvals, structured user questions, + attachments, MCP servers, models, thinking options, interaction modes, skills, and slash commands + to the extent the installed Kimi ACP implementation exposes them. +- Support Kimi-backed thread titles, branch names, commit messages, and change-request text. +- Support multiple isolated Kimi instances with independent binary paths, homes, credentials, + environments, display names, and accent colors. +- Preserve T3 Code's remote-ready architecture: Kimi runs on the T3 server machine and all clients + use the existing typed orchestration surface. +- Match existing provider naming, settings, status, logging, testing, and documentation conventions. + +## Non-goals + +- Reimplement Kimi's private runtime or depend on undocumented wire protocols. +- Emulate interactive TUI features that Kimi does not expose over ACP. +- Add a limited legacy `--prompt --output-format stream-json` session adapter for pre-ACP CLIs. +- Refactor Cursor, Grok, or the entire ACP provider stack as part of this feature. +- Advertise unsupported ACP features such as audio prompt blocks, logout, or unstable editor APIs. +- Bundle the Kimi CLI with T3 Code. + +## Product Naming and Presentation + +Use these names consistently: + +| Concept | Value | +| ------------------------------------------ | -------------------- | +| Driver kind | `kimi` | +| Default instance ID | `kimi` | +| Product label | Kimi | +| CLI label in diagnostics and documentation | Kimi Code CLI | +| Settings schema | `KimiSettings` | +| Driver | `KimiDriver` | +| Provider snapshot module | `KimiProvider` | +| Runtime adapter | `KimiAdapter` | +| ACP integration helpers | `KimiAcpSupport` | +| Auxiliary generation service | `KimiTextGeneration` | +| Home environment variable | `KIMI_CODE_HOME` | + +Kimi receives a dedicated `KimiIcon` based on the official mark and is present in provider settings, +model pickers, thread settings, new-thread flows, and mobile provider grouping. Its provider client +definition includes `badgeLabel: "Early Access"`. The default built-in Kimi instance is visible but +disabled until the user enables it, matching other Early Access providers. + +## Architecture + +### First-class driver + +Register `KimiDriver` in `BUILT_IN_DRIVERS`. The driver owns Kimi configuration decoding, process +environment construction, continuation identity, provider status, the runtime adapter, maintenance +capabilities, and auxiliary text generation. No orchestration branch should depend on `kimi`; the +existing `ProviderAdapter` and canonical runtime events remain the boundary. + +The driver supports multiple instances. Each call to `create` owns its subprocesses, scopes, event +stream, session map, pending interactions, and text-generation runtime. No mutable state is shared +between instances. + +### Dedicated adapter on shared ACP infrastructure + +`KimiAdapter` is provider-specific but builds on the existing `effect-acp` client, +`AcpSessionRuntime`, ACP event parsers, native logging, attachment store, and MCP conversion code. +Small provider-neutral helpers may be extracted from the Grok or Cursor adapters when reuse is +direct and independently testable. This work must not turn into a generalized ACP-provider rewrite. + +The adapter translates Kimi ACP messages into the same canonical events used by other providers: + +- assistant item start, text deltas, and completion +- plan updates +- tool start, progress, output, completion, and failure +- approval opened and resolved +- structured user-input opened and resolved +- turn completed, interrupted, or failed +- session state and resume cursor changes + +### Existing contracts first + +The current contracts already carry open provider driver slugs, opaque resume cursors, models, +provider option descriptors, slash commands, skills, attachments, approval requests, structured +questions, plans, and tool events. The implementation uses those contracts without adding Kimi-only +wire shapes. A contract change is allowed only when an upstream capability cannot be represented +faithfully by the existing canonical model and the change is useful across providers. + +## Configuration + +`KimiSettings` follows the annotated provider-settings schema convention and contains: + +- `enabled`, default `false` and hidden in the generic form +- `binaryPath`, default `kimi` +- `homePath`, an optional `KIMI_CODE_HOME` path +- `launchArgs`, optional extra global Kimi CLI arguments, tokenized with the repository's existing + shell-safe launch-argument utility and placed before the `acp` subcommand +- `customModels`, hidden in the generic form and merged with discovered models + +Provider-instance environment variables are merged through the existing secret-aware environment +system. An explicit instance environment value wins over the inherited process environment. +`homePath`, when non-empty, is resolved using the same cross-platform path conventions as other +provider homes and becomes `KIMI_CODE_HOME` for probes, sessions, and auxiliary generation. + +The adapter always owns the transport suffix and launches ` acp`. User launch +arguments may configure Kimi but may not replace the ACP subcommand or stdio transport. + +## Compatibility and Capability Negotiation + +Compatibility is capability-based rather than tied to a hard-coded version number. A health probe +runs `kimi --version`, starts `kimi acp`, sends `initialize`, and inspects the response. + +The following are required for a usable provider: + +- ACP initialization +- `session/new` +- `session/prompt` +- `session/cancel` +- `session/update` notifications carrying assistant output +- `session/request_permission` when the runtime asks for approval + +If this core surface is unavailable, Kimi remains visible with an actionable message to update to an +ACP-capable Kimi Code CLI. Optional capabilities are enabled only when advertised: + +- `session/load`, `session/resume`, and `session/list` +- session model or config-option mutation +- session modes +- image and embedded-resource prompt blocks +- stdio, HTTP, and SSE MCP forwarding +- available-command updates + +Missing `session/close` is handled by closing the adapter-owned process and scope. Missing `logout` +is documented; T3 does not pretend to provide it. Unknown extension messages are logged safely and +ignored unless they affect the active request. + +## Provider Status, Authentication, and Maintenance + +The provider snapshot distinguishes these states: + +1. disabled in T3 settings +2. binary not found +3. version probe failed or timed out +4. ACP handshake unsupported or incomplete +5. authentication required +6. ACP model discovery failed or timed out +7. ready + +Authentication is checked through ACP initialization/authentication and session startup. T3 does +not open an interactive login TUI inside a server process. When login is required, the status message +instructs the user to run `kimi login` on the environment hosting T3, with the same `KIMI_CODE_HOME` +when a custom home is configured. Provider-specific environment variables allow API-key based Kimi +configurations without sending secrets to clients. + +Maintenance metadata recognizes the official `@moonshot-ai/kimi-code` package where the existing +provider maintenance resolver can manage it. Standalone installations remain supported and receive +manual official upgrade guidance rather than an unsafe package-manager assumption. + +## Model and Option Discovery + +The status probe creates a short-lived ACP session after authentication and reads the returned model, +mode, and config-option state. Discovered models are normalized into `ServerProviderModel` entries and +merged with `customModels` without duplicates. + +Where Kimi changes config options after a model switch, the probe may switch models inside its +throwaway session and collect the resulting option descriptors without sending an LLM prompt. Probe +work is bounded by a total timeout and publishes the best valid partial model list if optional option +discovery fails. + +Model capabilities use T3's generic provider option descriptors. Kimi-advertised thinking and other +select or boolean values appear in both web and mobile model settings. On a real session, the adapter +applies configuration in this order: + +1. model +2. the selected model's provider options +3. interaction/plan mode +4. prompt + +The order matters because Kimi may change available options after a model switch. Unsupported or +stale stored options are omitted with a diagnostic log instead of failing the whole turn. + +Kimi supports in-session model switching when the active ACP session advertises it. Otherwise T3 +marks model changes as requiring a new thread/session instead of displaying a control that cannot +work. + +## Permission and Interaction Modes + +T3's four runtime modes map at the adapter boundary: + +- **Supervised** (`approval-required`): ask for command and file-change requests. +- **Auto-accept edits**: automatically accept file edits while continuing to ask for commands and + other actions. +- **Auto**: select Kimi's advertised automatic mode when one exists; otherwise fall back to + supervised behavior. +- **Full access**: automatically accept ordinary tool requests for the session. + +The adapter chooses ACP permission options by their semantic kinds (`allow_once`, `allow_always`, +`reject_once`) rather than assuming fixed option IDs. A user decision that Kimi cannot represent +returns a typed error instead of silently selecting a different outcome. + +T3's **Plan** interaction mode selects an advertised Kimi plan mode by exact ID/name aliases and +falls back to Kimi's config-option mode path where supported. If plan mode is not advertised, the +toggle is hidden for Kimi rather than simulated in the prompt. + +Question-shaped requests from Kimi are translated to canonical structured user input and remain +interactive even in Full access. Tool approval requests continue through the approval UI. Stopping, +interrupting, or losing the process resolves pending interactions as cancelled so no request hangs. + +## Session and Turn Lifecycle + +The opaque resume cursor is a versioned object containing the Kimi ACP session ID. Invalid or future +cursor versions are rejected safely and cause an explicit new-session or unsupported-resume outcome; +they never reach Kimi as unchecked input. + +Session startup performs: + +1. construct the isolated process environment +2. launch `kimi acp` +3. initialize ACP with T3 client capabilities +4. register permission, file, terminal, session-update, and extension handlers +5. load, resume, or create the Kimi session +6. apply the requested model and modes +7. publish a ready canonical session with its resume cursor + +`session/load` is preferred when history replay is required. `session/resume` is used when available +and replay is unnecessary. If a CLI advertises neither continuation method, existing threads fail +with upgrade guidance rather than silently creating unrelated Kimi history. + +Each T3 turn owns a stable turn ID even when the user steers while a Kimi prompt is still active. +Prompt settlement, interruption, queued event draining, and late notification checks are serialized +per thread. Late results from a replaced process or session cannot revive an interrupted or completed +turn. `stopSession` and driver scope closure terminate only processes created by that provider +instance. + +## Attachments, File Access, Terminals, and MCP + +- Text prompt content is sent as ACP text blocks. +- Images are loaded through the attachment store and sent as native ACP image blocks only when Kimi + advertises image input. +- Text resources and links use embedded ACP resource blocks when advertised. +- Other local files, including audio or video, are represented as explicit workspace file references + so Kimi can inspect them with its own tools when possible. T3 does not claim native audio ACP input. +- ACP file read/write reverse calls use T3's scoped filesystem handlers. +- Kimi shell commands execute on the T3 server environment. If Kimi uses local shell execution rather + than ACP terminal reverse calls, the canonical tool stream still reports their lifecycle and output. +- Configured stdio, HTTP, and SSE MCP servers are forwarded through `session/new` and continuation + calls according to Kimi's advertised MCP capabilities. Unsupported ACP-as-MCP transports are + omitted with a warning. + +## Skills and Slash Commands + +Kimi skills are discovered using its documented priority locations, including the configured +`KIMI_CODE_HOME`, user-level `.agents/skills`, project-level `.kimi-code/skills` and `.agents/skills`, +and valid extra skill directories from Kimi configuration where safely readable. Project entries win +name collisions. Malformed or unreadable skill metadata is skipped without failing provider health. + +The provider snapshot exposes discovered entries as `ServerProviderSkill` so the existing web and +mobile `$`/skill pickers and inline rendering work unchanged. + +The ACP runtime parses `available_commands_update` notifications during the bounded probe session +and exposes them as `ServerProviderSlashCommand`. Built-in commands and skill commands are deduped by +their canonical name. Commands remain plain prompt input; T3 does not try to execute TUI-only panels +it cannot host. + +## Auxiliary Text Generation + +`KimiTextGeneration` uses a short-lived, scoped Kimi ACP session with the selected model. It collects +assistant text, enforces the existing structured JSON prompts and sanitizers, and implements: + +- thread-title generation +- branch-name generation +- commit-message generation +- change-request title/body generation + +It has a bounded timeout, validates structured output, reports empty or cancelled responses, and +closes its ACP process on every exit path. It does not reuse a user's active thread or contaminate +that thread's Kimi history. + +## Error Handling and Observability + +All expected failures become typed provider or text-generation errors. Important cases include a +missing binary, malformed launch arguments, startup timeout, failed capability negotiation, required +authentication, invalid resume cursor, model/config rejection, malformed ACP events, prompt timeout, +process exit, unsupported attachments, and stale permission responses. + +Native ACP input/output may be recorded through the existing provider native logger for diagnostics. +Secrets and sensitive environment values are never included in provider snapshots or client events. +Logs prefer bounded lengths and structural metadata over raw payload dumping. Canonical provider +events remain the only client-facing runtime stream. + +Stopping a session, interrupting a turn, losing the subprocess, or closing an instance scope settles +pending approvals and user questions, drains owned fibers, and prevents resource leaks. + +## Surface Coverage + +- **Web:** provider settings, icon, status card, model picker, provider options, composer permission + and plan controls, skills, slash commands, and thread continuation. +- **Desktop:** the shared web surface plus server-side binary discovery and environment behavior in + the Electron-hosted server. +- **Mobile:** provider/model grouping, Early Access presentation where shown, options, permission and + plan controls, skills, slash commands, approvals, questions, and remote thread continuation. +- **Connection modes:** local, relay, and tunnel require no Kimi-specific client path because all CLI + work remains server-side. +- **Reverse states:** Kimi sessions can be stopped and restarted/resumed; modes can return to their + non-plan and supervised states; provider instances can be disabled and re-enabled. + +## Testing Strategy + +Implementation follows red-green-refactor. Every behavior change begins with a focused failing test. + +### Contracts and settings + +- `KimiSettings` defaults, annotations, decoding, normalization, and persistence +- default Kimi instance migration/bootstrap behavior +- secret-aware environment merging and `KIMI_CODE_HOME` resolution + +### CLI and provider snapshot + +- command and argument construction on Windows, macOS, and Linux +- disabled, missing, timed-out, upgrade-required, unauthenticated, discovery-failed, and ready states +- version parsing, maintenance metadata, model deduplication, and option mapping +- skill and slash-command discovery, priority, deduplication, and malformed input + +### ACP runtime and adapter + +A deterministic Kimi ACP mock peer covers: + +- initialize and capability negotiation +- new, load, and resume session paths +- model and config-option application order +- plan/default mode switching +- all four permission modes and semantic permission option selection +- assistant streaming and segmented messages +- plans and complete tool lifecycle events +- approvals and structured questions +- text, image, and embedded-resource prompts +- stdio, HTTP, and SSE MCP forwarding +- steering, interruption, stop, prompt failure, process exit, and late events +- resume cursor validation and history replay +- multiple simultaneous provider instances with no shared state + +### Auxiliary generation + +- each generated content type +- selected model application +- structured output validation, sanitization, timeout, cancellation, empty output, and cleanup + +### Clients + +- web provider metadata, icon lookup, settings rendering, model groups/options, commands, and skills +- mobile provider labels, model groups/options, commands, skills, and continuation selection +- unknown-provider fallbacks remain intact + +### Focused integration + +Add a server integration fixture for a Kimi session flowing through orchestration ingestion and +checkpointing. A separate opt-in live smoke probe performs only an ACP handshake/model discovery +against an installed official Kimi CLI; it does not send a billable LLM prompt. + +Run only focused tests, targeted lint/type checks, and relevant builds. A real browser or mobile +client pass requires explicit approval under the repository's computer-use policy. + +## Documentation + +- Add `docs/user/providers-kimi.md` with installation, login, custom home, multiple-instance, + permissions, models, and troubleshooting guidance in shipped-product language. +- Update `docs/internals/providers.md` with the sixth built-in driver and its ACP boundary. +- Update relevant provider lists or screenshots only where the product surface requires it. +- Document upstream ACP limitations without presenting them as T3 functionality. + +## Upstream Limitations + +The current official Kimi ACP documentation does not expose `session/close`, logout, native audio +prompt blocks, ACP terminal reverse calls, or most unstable editor APIs. T3 compensates only where it +can do so faithfully—for example, closing the owned process when `session/close` is absent. Other +features are hidden or documented until Kimi exposes them. + +Primary upstream references: + +- +- +- +- + +## Rollout + +Ship Kimi with an **Early Access** badge and default it to disabled. The badge can be removed in a +later focused change after supported Kimi versions have been exercised across operating systems, +authentication methods, model configurations, long-running sessions, reconnects, and remote clients. +No intentionally incomplete code path is justified by the badge. diff --git a/docs/superpowers/specs/2026-08-12-kimi-model-discovery-design.md b/docs/superpowers/specs/2026-08-12-kimi-model-discovery-design.md new file mode 100644 index 000000000000..ba216d6f582c --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-kimi-model-discovery-design.md @@ -0,0 +1,123 @@ +# Kimi Model Discovery and Thinking Controls Design + +## Summary + +Fix Kimi's empty default model picker by discovering models from the generic ACP `model` +configuration option used by Kimi Code CLI 0.29.1 and newer. Preserve the legacy ACP `models` +response path, then probe each discovered model through ACP to capture its model-specific thinking +choices. + +Kimi's official `K2.7 Coding Highspeed` entry remains a distinct model. T3 does not invent a +provider option that the CLI cannot apply. + +## Problem + +T3 currently reads `session/new.models.availableModels`. Kimi Code CLI 0.29.1 omits that legacy +field and instead returns these entries in a `configOptions` select with `category: "model"`: + +- `kimi-code/kimi-for-coding` — K2.7 Coding +- `kimi-code/kimi-for-coding-highspeed` — K2.7 Coding Highspeed +- `kimi-code/k3` — K3 +- `kimi-code/k3-256k` — K3-256k + +Because the provider snapshot sees no models, T3 cannot switch models and never probes the +model-specific configuration returned after a switch. K3's `Low`, `High`, and `Max` thinking levels +therefore also remain hidden. + +## Goals + +- Populate the Kimi model picker from the installed CLI and authenticated account. +- Mark the model config option's `currentValue` as the default model. +- Show K2.7 Highspeed as the official speed-oriented model. +- Show the thinking values each model advertises after an ACP model switch. +- Preserve support for older Kimi ACP versions that return the legacy `models` object. +- Avoid contract, web, mobile, and orchestration changes. + +## Non-goals + +- Hard-code a model catalog in T3. +- Add a synthetic `fastMode` or `speed` option. +- Expose thinking values not accepted by the active CLI. +- Change Kimi's Early Access status or unrelated adapter behavior. + +## Provider Adapter Decisions + +| Provider | Decision | Reason | +| -------- | ---------------------- | -------------------------------------------------------------------------------------- | +| Kimi | Update model discovery | Kimi Code CLI 0.29.1 advertises its catalog through an ACP model config option. | +| Codex | Unchanged | Codex uses its own app-server model discovery and does not consume Kimi ACP responses. | +| Claude | Unchanged | Claude uses its SDK adapter and provider-specific option mapping. | +| Cursor | Unchanged | Cursor's ACP model behavior is independent of Kimi's response shape. | +| Grok | Unchanged | Grok retains its existing ACP model-state implementation. | +| OpenCode | Unchanged | OpenCode uses its own provider API and model discovery path. | + +No provider is marked unsupported: this is a compatibility fix for Kimi's provider boundary, and +the other five adapters continue using their existing supported paths. + +## Design + +### Normalize ACP model discovery at the provider boundary + +`KimiProvider` will derive a single internal model discovery result from the session setup response: + +1. Find the select config option whose category is `model`. +2. When it contains valid values, use it as the authoritative current model and model catalog. +3. Otherwise, fall back to `models.currentModelId` and `models.availableModels` from older ACP + implementations. +4. Convert config-option values to ACP-shaped model entries, using the option value as the model ID + and its name as the display name. +5. Ignore blank and duplicate IDs while retaining source order. + +This keeps the rest of provider snapshot construction unchanged and makes compatibility explicit in +one helper. + +### Probe model-specific thinking controls + +The existing bounded discovery session will switch through every normalized model, read the updated +config options, and restore the original model. Generic option conversion will continue to exclude +the `model` and `mode` selectors themselves while retaining Kimi's `thinking` selector. + +For the current managed catalog this yields: + +- K2.7 Coding and K2.7 Coding Highspeed: `Thinking = On`, because they are always-thinking models. +- K3 and K3-256k: `Thinking = Low | High | Max`, with `High` selected by default. + +The adapter already applies the selected model before provider options and routes both through +`session/set_config_option`, so no session execution change is required. + +### Failure behavior + +If no valid model source exists, Kimi continues to fall back to user-configured custom models and +the existing provider status behavior. If one model's option probe fails, that model remains visible +with empty capabilities while discovery continues. Model restoration remains best-effort inside the +throwaway probe session. + +## Surface Coverage + +The server provider snapshot is shared by web, desktop, and mobile. Existing generic model and +provider-option controls render the normalized models and thinking selector on all clients, so no +client-specific branch is needed. Local, relay, and tunnel connections keep the same server-side +Kimi process behavior. + +## Testing + +Extend the focused Kimi provider fixture to match Kimi Code CLI 0.29.1: + +- `session/new` contains model, thinking, and mode config options but no legacy `models` field. +- Discovery returns all four model IDs in source order. +- The current K2.7 model is marked as default. +- Switching to K3 returns `Low`, `High`, and `Max`, with `High` as the current/default value. +- The Highspeed model remains a normal model entry, not a synthetic option. +- A separate compatibility assertion keeps the legacy `models` path working. +- Blank and duplicate config-option model values are ignored. + +Run the focused Kimi provider tests and the server typecheck. The optional real-CLI ACP probe may be +run because it creates a throwaway session without sending a billable prompt. + +## Upstream Evidence + +- Kimi Code CLI 0.29.1 live ACP `session/new` response on Windows, inspected without a prompt. +- Kimi Code CLI `provider list --json`, which reports the same four managed model aliases. +- Kimi ACP model switching, which advertises and accepts K3 thinking values `low`, `high`, and `max`. +- Official Kimi provider and model documentation: + diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..337c546f34a6 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -46,13 +46,14 @@ yay -S t3code-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Provider | CLI | Default binary | Log in with | +| ---------- | -------------------------------------------------------- | -------------- | --------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| Kimi | [Kimi Code CLI](https://moonshotai.github.io/kimi-code/) | `kimi` | `kimi login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. @@ -74,7 +75,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For provider-specific setup and multi-account guidance, see [Codex](./providers-codex.md), +[Claude](./providers-claude.md), and [Kimi](./providers-kimi.md). ## Next Steps diff --git a/docs/user/providers-kimi.md b/docs/user/providers-kimi.md new file mode 100644 index 000000000000..6756cffd4392 --- /dev/null +++ b/docs/user/providers-kimi.md @@ -0,0 +1,48 @@ +# Kimi + +Kimi support is available as an Early Access provider through the official Kimi Code CLI and its +ACP transport. The Kimi process runs on the machine hosting your T3 Code server, including when you +control it from another browser or the mobile app. + +## Install and log in + +Install the official CLI: + +```bash +npm install -g @moonshot-ai/kimi-code +kimi login +``` + +T3 Code requires Kimi CLI 0.29.0 or newer. Earlier versions collapse selectable thinking effort +levels into a single `Thinking On` value over ACP. Update Kimi from provider settings or run the +install command again to expose the levels supported by each model. + +Confirm that the same shell which starts T3 Code can run `kimi --version`. Then open +**Settings**, select **Kimi**, and enable the provider. If the executable is not on the server's +`PATH`, set **Binary path** to its full path. + +T3 Code starts Kimi through `kimi acp`. Models, thinking controls, modes, and slash commands are +read from the CLI at runtime, so the available choices follow the installed Kimi version and your +account rather than a catalog built into T3 Code. + +## Separate accounts and configuration + +Add multiple Kimi provider instances when you need separate accounts or configurations. Set a +different **KIMI_CODE_HOME path** on each instance; T3 Code uses that directory for the instance's +Kimi credentials, configuration, sessions, and user skills. Environment variables configured on a +provider instance are applied only to that instance's server-side Kimi process. + +## Sessions, permissions, and remote use + +Kimi sessions can be continued using the session capabilities advertised by the installed CLI. +T3 Code maps Kimi's ACP permission requests into the same approval controls used by other +providers. Plan and other interaction modes appear only when Kimi advertises them. + +When T3 Code is hosted remotely, install and authenticate Kimi on the server—not on the phone, +tablet, or browser connecting to it. Attachments and project paths are resolved on that server. + +## Early Access limitations + +Kimi support follows the capabilities exposed by the CLI's ACP implementation. Unsupported input +types, session operations, or provider-side rollback remain unavailable until Kimi advertises them. +T3 Code does not fabricate missing models, modes, configuration values, or protocol features. diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts new file mode 100644 index 000000000000..018e9ec59588 --- /dev/null +++ b/packages/contracts/src/model.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderDriverKind } from "./providerInstance.ts"; +import { PROVIDER_DISPLAY_NAMES } from "./model.ts"; +import { normalizeModelSlug } from "../../shared/src/model.ts"; + +describe("Kimi provider model metadata", () => { + it("uses Kimi as the provider display name", () => { + expect(PROVIDER_DISPLAY_NAMES[ProviderDriverKind.make("kimi")]).toBe("Kimi"); + }); + + it("preserves an unknown discovered model slug", () => { + const kimi = ProviderDriverKind.make("kimi"); + + expect(normalizeModelSlug("kimi-code/k3", kimi)).toBe("kimi-code/k3"); + }); +}); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..6f4d3ac15d5d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -221,5 +222,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [KIMI_DRIVER_KIND]: "Kimi", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..f3be51ded1fd 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -114,6 +114,29 @@ describe("ClientSettings sidebar", () => { }); describe("ServerSettings.providerInstances (slice-2 invariant)", () => { + it("decodes Kimi provider defaults", () => { + const settings = decodeServerSettings({ providers: { kimi: {} } }); + + expect(settings.providers.kimi).toEqual({ + enabled: false, + binaryPath: "kimi", + homePath: "", + launchArgs: "", + customModels: [], + }); + }); + + it("decodes and trims Kimi provider patches", () => { + const patch = decodeServerSettingsPatch({ + providers: { kimi: { homePath: " ~/.kimi-work ", launchArgs: " --agent coder " } }, + }); + + expect(patch.providers?.kimi).toEqual({ + homePath: "~/.kimi-work", + launchArgs: "--agent coder", + }); + }); + it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ instanceId: ProviderInstanceId.make("codex"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ee1970639adf..27a1b57bf339 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -422,6 +422,44 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const KimiSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kimi").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kimi Code CLI binary used by this instance.", + providerSettingsForm: { placeholder: "kimi", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "KIMI_CODE_HOME path", + description: "Custom Kimi Code home, configuration, credentials, and sessions directory.", + providerSettingsForm: { placeholder: "~/.kimi-code", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional global CLI arguments passed before kimi acp on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath", "homePath", "launchArgs"] }, +); +export type KimiSettings = typeof KimiSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -601,6 +639,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kimi: KimiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -697,6 +736,14 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KimiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -744,6 +791,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + kimi: Schema.optionalKey(KimiSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),