diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..c327a0929d6d 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,4 +1,4 @@ -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { @@ -39,6 +39,19 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "kimi") { + const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + {/* Stylized "K" mark matching the web KimiIcon: upright stem plus two + angled strokes, with the upper arm ending in Kimi's dot accent. */} + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 70227cdd4ebf..64374da6f264 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,6 +52,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + // Authentication mutates provider credentials on the host. + [WS_METHODS.kimiAuthSignIn]: AuthOrchestrationOperateScope, + [WS_METHODS.kimiAuthSignOut]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/KimiDriver.ts b/apps/server/src/provider/Drivers/KimiDriver.ts new file mode 100644 index 000000000000..f3fb7e935c54 --- /dev/null +++ b/apps/server/src/provider/Drivers/KimiDriver.ts @@ -0,0 +1,268 @@ +import * as NodeOS from "node:os"; + +import { KimiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +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 Ref from "effect/Ref"; +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 { makeKimiTurnActivity, type KimiTurnActivity } from "../acp/KimiAcpSupport.ts"; +import { makeKimiAdapter } from "../Layers/KimiAdapter.ts"; +import { + buildInitialKimiProviderSnapshot, + enrichKimiSnapshot, + isTransientKimiProbeClassification, + probeKimiProviderStatus, + type KimiModelDiscoveryCache, + type KimiProviderProbeResult, +} from "../Layers/KimiProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeKimiSettings = Schema.decodeSync(KimiSettings); + +export const resolveKimiDriverBinaryPath = Effect.fn("resolveKimiDriverBinaryPath")(function* ( + config: Pick, + options?: { readonly homeDirectory?: string }, +) { + const configuredBinaryPath = config.binaryPath.trim(); + if (configuredBinaryPath && configuredBinaryPath !== "kimi") { + return configuredBinaryPath; + } + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const officialBinaryPath = path.join( + options?.homeDirectory ?? NodeOS.homedir(), + ".kimi-code", + "bin", + platform === "win32" ? "kimi.exe" : "kimi", + ); + const officialBinaryExists = yield* fileSystem + .exists(officialBinaryPath) + .pipe(Effect.orElseSucceed(() => false)); + return officialBinaryExists ? officialBinaryPath : configuredBinaryPath || "kimi"; +}); + +export function runKimiProbeWithActiveTurnDeferral(input: { + readonly turnActivity: KimiTurnActivity; + readonly probe: Effect.Effect; +}): Effect.Effect { + return Effect.gen(function* () { + while (!(yield* input.turnActivity.beginProbeIfIdle)) { + const activeCount = yield* input.turnActivity.activeCount; + yield* Effect.logDebug("Deferring Kimi provider probe until active turns settle.", { + activeTurnCount: activeCount, + }); + yield* input.turnActivity.awaitIdle; + } + return yield* input.probe.pipe(Effect.ensuring(input.turnActivity.endProbe)); + }); +} + +const DRIVER_KIND = ProviderDriverKind.make("kimi"); +// The Kimi CLI installs via Moonshot's install script (or a global npm +// package the script manages itself), so no T3-managed update path applies; +// updates stay manual. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type KimiDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const stabilizeKimiProviderProbe = Effect.fn("stabilizeKimiProviderProbe")(function* ( + lastKnownGoodRef: Ref.Ref, + result: KimiProviderProbeResult, +) { + if (result.classification._tag === "healthy") { + yield* Ref.set(lastKnownGoodRef, result.snapshot); + return result.snapshot; + } + if (isTransientKimiProbeClassification(result.classification)) { + return (yield* Ref.get(lastKnownGoodRef)) ?? result.snapshot; + } + yield* Ref.set(lastKnownGoodRef, null); + return result.snapshot; +}); + +export const KimiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kimi", + supportsMultipleInstances: true, + }, + configSchema: KimiSettings, + defaultConfig: (): KimiSettings => decodeKimiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const resolvedBinaryPath = yield* resolveKimiDriverBinaryPath(config); + const effectiveConfig = { + ...config, + binaryPath: resolvedBinaryPath, + enabled, + } satisfies KimiSettings; + const turnActivity = yield* makeKimiTurnActivity; + const lastKnownGoodRef = yield* Ref.make(null); + const discoveryCacheRef = yield* Ref.make(undefined); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeKimiAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + turnActivity, + }); + const textGeneration = yield* makeKimiTextGeneration(effectiveConfig, processEnv); + + const probeProvider = Effect.gen(function* () { + const discoveryCache = yield* Ref.get(discoveryCacheRef); + const result = yield* probeKimiProviderStatus( + effectiveConfig, + processEnv, + discoveryCache ? { discoveryCache } : {}, + ); + if (result.classification._tag === "healthy") { + yield* Ref.set(discoveryCacheRef, result.discoveryCache); + } else if (!isTransientKimiProbeClassification(result.classification)) { + yield* Ref.set(discoveryCacheRef, undefined); + } + return yield* stabilizeKimiProviderProbe(lastKnownGoodRef, { + ...result, + snapshot: stampIdentity(result.snapshot), + }); + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const checkProvider = runKimiProbeWithActiveTurnDeferral({ + turnActivity, + probe: probeProvider, + }); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const managedSnapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialKimiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichKimiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the Kimi provider snapshot.", + cause, + }), + ), + ); + const snapshot = { + maintenanceCapabilities: managedSnapshot.maintenanceCapabilities, + getSnapshot: managedSnapshot.getSnapshot, + refresh: Ref.set(discoveryCacheRef, undefined).pipe( + Effect.andThen(managedSnapshot.refresh), + ), + get streamChanges() { + return managedSnapshot.streamChanges; + }, + }; + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; 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..014711331fee --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.test.ts @@ -0,0 +1,2027 @@ +// @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 { assert, it } from "@effect/vitest"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + ApprovalRequestId, + KimiSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { makeKimiTurnActivity, type KimiTurnActivity } from "../acp/KimiAcpSupport.ts"; +import { + clearSettledKimiInterruptedTurnIds, + kimiPromptSettlementBelongsToContext, + makeKimiAdapter, +} from "./KimiAdapter.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); +const mockAgentCommand = process.execPath; +const KIMI_PROVIDER = ProviderDriverKind.make("kimi"); +const KIMI_INSTANCE = ProviderInstanceId.make("kimi"); +const KIMI_MOCK_AGENT_SOURCE = String.raw` +import { appendFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; +const exitOnPrompt = process.env.T3_ACP_EXIT_ON_PROMPT === "1"; +const terminalCommandJson = process.env.T3_ACP_TERMINAL_COMMAND; +const planFlow = process.env.T3_ACP_PLAN_FLOW === "1"; +const permissionAfterCancel = process.env.T3_ACP_PERMISSION_AFTER_CANCEL === "1"; +const emitSteerPreparedMarker = process.env.T3_ACP_STEER_PREPARED_MARKER === "1"; +const sessionId = "mock-kimi-session-1"; +let currentMode = "default"; +let currentModel = "default"; +let currentReasoning = "medium"; +let permissionId = 0; +const pendingPermissions = new Map(); +let clientRequestId = 0; +const pendingClientRequests = new Map(); +let promptOrdinal = 0; +let cancelRequested = false; +const cancelWaiters = []; +let planDismissals = 0; + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} + +function logLine(method, params) { + if (requestLogPath) { + appendFileSync(requestLogPath, JSON.stringify({ method, params }) + "\n", "utf8"); + } +} + +function sendClientRequest(method, params) { + return new Promise((resolve, reject) => { + const id = "client-req-" + String(++clientRequestId); + pendingClientRequests.set(id, { resolve, reject }); + send({ jsonrpc: "2.0", id, method, params }); + }); +} + +const BASH_PERMISSION_OPTIONS = [ + { optionId: "approve_once", name: "Approve once", kind: "allow_once" }, + { optionId: "approve_always", name: "Approve for this session", kind: "allow_always" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, +]; + +const EXIT_PLAN_PERMISSION_OPTIONS = [ + { optionId: "plan_approve", name: "Approve", kind: "allow_once" }, + { optionId: "plan_revise", name: "Revise", kind: "reject_once" }, + { optionId: "plan_reject_and_exit", name: "Reject and Exit", kind: "reject_once" }, +]; + +// Mirrors kimi-cli 0.37.2 wire shapes: the tool call carries composed text +// content entries instead of rawInput. +function bashPermissionToolCall() { + return { + toolCallId: "tool-bash-" + String(permissionId + 1), + title: "Bash", + kind: "execute", + status: "pending", + rawInput: {}, + content: [ + { + type: "content", + content: { + type: "text", + text: "Requesting approval to Running: echo mock-approved-command", + }, + }, + ], + }; +} + +function exitPlanModeToolCall() { + return { + toolCallId: "tool-exit-plan-" + String(permissionId + 1), + title: "ExitPlanMode", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Plan saved to: D:/mock/plans/mock-plan.md\n\n# Plan: Mock landing page\n\n## Steps\n- write the plan\n- ship it", + }, + }, + { + type: "content", + content: { + type: "text", + text: "Requesting approval to Presenting plan and exiting plan mode", + }, + }, + ], + }; +} + +function requestPermission(promptId, toolCall, options) { + const id = "permission-" + String(++permissionId); + pendingPermissions.set(id, { promptId, planDecision: toolCall.title === "ExitPlanMode" }); + logLine("mock/permission_request", { id, title: toolCall.title }); + send({ + jsonrpc: "2.0", + id, + method: "session/request_permission", + params: { sessionId, toolCall, options }, + }); +} + +async function runTerminalCommandFlow(promptId) { + const spec = JSON.parse(terminalCommandJson); + try { + const created = await sendClientRequest("terminal/create", { + sessionId, + command: spec.command, + args: spec.args ?? [], + env: [{ name: "T3_MOCK_TERMINAL_ENV", value: "from-mock-agent" }], + ...(spec.outputByteLimit !== undefined ? { outputByteLimit: spec.outputByteLimit } : {}), + }); + if (spec.cancelAfterWait) { + // Surface "terminal created" so the test can interrupt while this flow + // is parked in wait_for_exit below. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "terminal-created" }, + }, + }, + }); + } + const waitResult = await sendClientRequest("terminal/wait_for_exit", { + sessionId, + terminalId: created.terminalId, + }); + const outputResult = await sendClientRequest("terminal/output", { + sessionId, + terminalId: created.terminalId, + }); + await sendClientRequest("terminal/release", { + sessionId, + terminalId: created.terminalId, + }); + logLine("mock/terminal_result", { created, waitResult, outputResult }); + if (spec.cancelAfterWait) { + // Signal that the killed terminal stayed readable through release, so + // the test can assert on the logged results without racing this flow. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "terminal-finished" }, + }, + }, + }); + // Mirror real Kimi: the prompt only responds once session/cancel + // arrives, and cancel is honored only after the blocked + // terminal/wait_for_exit resolves. + if (cancelRequested) { + completePrompt(promptId, "cancelled"); + } else { + cancelWaiters.push(() => completePrompt(promptId, "cancelled")); + } + return; + } + completePrompt(promptId, "end_turn"); + } catch (err) { + logLine("mock/terminal_error", { message: String(err) }); + completePrompt(promptId, "end_turn"); + } +} + +function configOptions() { + const options = [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: currentMode, + options: ["default", "plan", "auto", "yolo"].map((value) => ({ value, name: value })), + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModel, + options: [ + { value: "default", name: "Default" }, + { value: "gpt-5.4", name: "GPT-5.4" }, + ], + }, + ]; + if (currentModel === "gpt-5.4") { + options.push({ + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: currentReasoning, + options: ["low", "medium", "high"].map((value) => ({ value, name: value })), + }); + } + return options; +} + +function result(id, value) { + send({ jsonrpc: "2.0", id, result: value }); +} + +function error(id, message) { + send({ jsonrpc: "2.0", id, error: { code: -32603, message } }); +} + +function completePrompt(id, stopReason) { + result(id, { stopReason }); +} + +function handlePermissionResponse(message) { + const pending = pendingPermissions.get(String(message.id)); + if (!pending) return; + pendingPermissions.delete(String(message.id)); + logLine("mock/permission_response", { id: String(message.id), result: message.result }); + const outcome = message.result?.outcome; + const selected = outcome?.outcome === "selected"; + if (pending.planDecision) { + handlePlanDecisionResponse(pending.promptId, outcome, selected); + } else { + completePrompt(pending.promptId, selected ? "end_turn" : "cancelled"); + } + // Deterministic post-response marker: the RPC layer can unwind the prompt + // client-side on cancel, so tests cannot use prompt settlement to observe + // that the permission response arrived. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "permission-resolved" }, + }, + }, + }); +} + +// Mirrors kimi-cli 0.37.2 observed live: a "cancelled" ExitPlanMode answer +// reads as a dismissed approval dialog, NOT as an end-turn signal, so the +// agent retries the request; after three dismissals it gives up, prints the +// plan as plain text, and ends the turn. A selected plan_approve leaves plan +// mode natively (no config_option_update is emitted) and the same turn +// continues; plan_revise keeps plan mode and presents the plan again. +function handlePlanDecisionResponse(promptId, outcome, selected) { + if (selected && outcome?.optionId === "plan_approve") { + currentMode = "default"; + planDismissals = 0; + completePrompt(promptId, "end_turn"); + return; + } + if (selected && outcome?.optionId === "plan_revise") { + planDismissals = 0; + requestPermission(promptId, exitPlanModeToolCall(), EXIT_PLAN_PERMISSION_OPTIONS); + return; + } + planDismissals += 1; + if (planDismissals < 3) { + requestPermission(promptId, exitPlanModeToolCall(), EXIT_PLAN_PERMISSION_OPTIONS); + return; + } + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "plan-printed-after-dismissals" }, + }, + }, + }); + completePrompt(promptId, "end_turn"); +} + +function notifyConfigOptions() { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { sessionUpdate: "config_option_update", configOptions: configOptions() }, + }, + }); +} + +function handleRequest(message) { + if (requestLogPath) { + appendFileSync(requestLogPath, JSON.stringify(message) + "\n", "utf8"); + } + const params = message.params ?? {}; + switch (message.method) { + case "initialize": + result(message.id, { protocolVersion: 1, agentCapabilities: { loadSession: true } }); + return; + case "authenticate": + result(message.id, {}); + return; + case "session/new": + result(message.id, { sessionId, configOptions: configOptions() }); + return; + case "session/load": + result(message.id, { configOptions: configOptions() }); + return; + case "session/set_mode": + currentMode = String(params.modeId); + result(message.id, {}); + notifyConfigOptions(); + return; + case "session/set_model": + currentModel = String(params.modelId); + result(message.id, {}); + return; + case "session/set_config_option": + if (params.configId === "mode") currentMode = String(params.value); + if (params.configId === "model") currentModel = String(params.value); + if (params.configId === "reasoning") currentReasoning = String(params.value); + result(message.id, { configOptions: configOptions() }); + notifyConfigOptions(); + if (emitSteerPreparedMarker && params.configId === "model") { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "steer-prepared" }, + }, + }, + }); + } + return; + case "session/prompt": { + if (exitOnPrompt) { + process.exit(7); + } + promptOrdinal += 1; + if (terminalCommandJson) { + const spec = JSON.parse(terminalCommandJson); + if (spec.cancelAfterWait && promptOrdinal > 1) { + // Only the first prompt blocks on the never-exiting terminal; + // follow-ups behave normally so the session stays usable. + completePrompt(message.id, "end_turn"); + return; + } + runTerminalCommandFlow(message.id); + return; + } + if (permissionAfterCancel) { + if (promptOrdinal > 1) { + // Only the first prompt parks; follow-ups behave normally so the + // session stays usable after the interrupt. + completePrompt(message.id, "end_turn"); + return; + } + // Surface that the prompt is parked so the test interrupts + // deterministically, then wait for session/cancel: the drained + // waiter fires a late permission request BEFORE answering the + // prompt; the post-stop gate must cancel it without opening an + // approval card. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "prompt-parked" }, + }, + }, + }); + cancelWaiters.push(() => + requestPermission(message.id, bashPermissionToolCall(), BASH_PERMISSION_OPTIONS), + ); + return; + } + if (planFlow && promptOrdinal === 1) { + // The first prompt ends in the plan decision; follow-ups are tool gates. + requestPermission(message.id, exitPlanModeToolCall(), EXIT_PLAN_PERMISSION_OPTIONS); + return; + } + if (!emitToolCalls && !planFlow) { + completePrompt(message.id, "end_turn"); + return; + } + requestPermission(message.id, bashPermissionToolCall(), BASH_PERMISSION_OPTIONS); + return; + } + case "session/cancel": + result(message.id, {}); + cancelRequested = true; + for (const [id, pending] of pendingPermissions) { + pendingPermissions.delete(id); + completePrompt(pending.promptId, "cancelled"); + } + for (const waiter of cancelWaiters.splice(0)) { + waiter(); + } + if (emitLateUpdateAfterCancel) { + setImmediate(() => + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "late after cancel" }, + }, + }, + }), + ); + } + return; + default: + error(message.id, "Unsupported method: " + String(message.method)); + } +} + +createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (message.method) { + handleRequest(message); + return; + } + const pendingClient = pendingClientRequests.get(String(message.id)); + if (pendingClient) { + pendingClientRequests.delete(String(message.id)); + if (message.error) { + pendingClient.reject(new Error(JSON.stringify(message.error))); + } else { + pendingClient.resolve(message.result); + } + return; + } + handlePermissionResponse(message); +}); +`; + +async function makeMockKimiWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-acp-mock-")); + const windows = NodePath.sep === "\\"; + const agentPath = NodePath.join(dir, "mock-kimi-agent.mjs"); + const wrapperPath = NodePath.join(dir, windows ? "fake-kimi.cmd" : "fake-kimi"); + const entries = Object.entries(extraEnv ?? {}); + const script = windows + ? [ + "@echo off", + ...entries.map(([key, value]) => `set "${key}=${value}"`), + `"${mockAgentCommand}" "${agentPath}" %*`, + ].join("\r\n") + : [ + "#!/bin/sh", + ...entries.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`), + `exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(agentPath)} "$@"`, + ].join("\n"); + await NodeFSP.writeFile(agentPath, KIMI_MOCK_AGENT_SOURCE, "utf8"); + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + if (!windows) { + await NodeFSP.chmod(wrapperPath, 0o755); + } + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const kimiAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-kimi-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeKimiAdapter(decodeKimiSettings({ enabled: true, binaryPath }), options).pipe(Effect.orDie); + +const startTestSession = ( + adapter: Effect.Success>, + threadId: ThreadId, + runtimeMode: "approval-required" | "full-access" = "approval-required", +) => + adapter.startSession({ + threadId, + provider: KIMI_PROVIDER, + cwd: process.cwd(), + runtimeMode, + }); + +function terminalEvents(events: ReadonlyArray, threadId: ThreadId) { + return events.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); +} + +it("requires a settlement to match the live Kimi turn", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse( + kimiPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isFalse( + kimiPromptSettlementBelongsToContext({ + liveAcpSessionId: "replacement-session", + expectedAcpSessionId: "stale-session", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + kimiPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + +it("clears interrupted turn ids after a newer turn settles", () => { + const interruptedTurnIds = new Set([TurnId.make("interrupted-1"), TurnId.make("interrupted-2")]); + clearSettledKimiInterruptedTurnIds(interruptedTurnIds); + assert.strictEqual(interruptedTurnIds.size, 0); +}); + +it.layer(kimiAdapterTestLayer)("KimiAdapterLive", (it) => { + it.effect("tracks activity through a supervised prompt completion", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-activity-completion"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const requestOpened = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "request.opened" + ? Deferred.succeed(requestOpened, event).pipe(Effect.asVoid) + : event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "complete with approval", attachments: [] }) + .pipe(Effect.forkChild); + const opened = yield* Deferred.await(requestOpened).pipe(Effect.timeout("5 seconds")); + assert.equal(yield* turnActivity.activeCount, 1); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(opened.requestId)), + "accept", + ); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(yield* turnActivity.activeCount, 0); + const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* adapter.stopSession(threadId); + assert.equal(yield* turnActivity.activeCount, 0); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("auto-approves only full-access yolo prompts", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-yolo-auto-approval"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const turnCompleted = 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(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId, "full-access"); + yield* adapter.sendTurn({ threadId, input: "auto approve", attachments: [] }); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf( + events.filter((event) => event.type === "request.opened"), + 0, + ); + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns activity to idle when prompt preparation fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-preparation-failure-idle"); + const wrapperPath = yield* Effect.promise(() => makeMockKimiWrapper()); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "invalid attachment", + attachments: [ + { + type: "image", + id: "missing-image", + name: "missing.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(yield* turnActivity.activeCount, 0); + assert.lengthOf(terminalEvents(events, threadId), 0); + const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("interrupts during preparation before starting the prompt RPC", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-interrupt-during-preparation"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-preparation-interrupt-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const baseActivity = yield* makeKimiTurnActivity; + const markedActive = yield* Deferred.make(); + const turnActivity: KimiTurnActivity = { + ...baseActivity, + markActive: (activeThreadId) => + baseActivity + .markActive(activeThreadId) + .pipe(Effect.andThen(Deferred.succeed(markedActive, undefined)), Effect.asVoid), + }; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "interrupt preparation", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(markedActive).pipe(Effect.timeout("5 seconds")); + yield* Effect.yieldNow; + const activeSession = (yield* adapter.listSessions()).find( + (entry) => entry.threadId === threadId, + ); + assert.isDefined(activeSession?.activeTurnId); + yield* adapter + .interruptTurn(threadId, activeSession?.activeTurnId) + .pipe(Effect.timeout("5 seconds")); + yield* Fiber.await(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + for (let attempt = 0; attempt < 4; attempt += 1) { + yield* Effect.yieldNow; + } + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.notInclude( + requests.map((request) => request.method), + "session/prompt", + ); + assert.lengthOf( + events.filter( + (event) => event.type === "turn.started" && String(event.threadId) === String(threadId), + ), + 0, + ); + assert.lengthOf(terminalEvents(events, threadId), 0); + assert.equal(yield* turnActivity.activeCount, 0); + const readySession = (yield* adapter.listSessions()).find( + (entry) => entry.threadId === threadId, + ); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("steers an in-flight turn and only the last prompt settles it", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-steer-then-complete"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const baseActivity = yield* makeKimiTurnActivity; + const activityMarks = yield* Ref.make(0); + const twoPromptsActive = yield* Deferred.make(); + const turnActivity: KimiTurnActivity = { + ...baseActivity, + markActive: (activeThreadId) => + baseActivity + .markActive(activeThreadId) + .pipe( + Effect.andThen( + Ref.updateAndGet(activityMarks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Deferred.succeed(twoPromptsActive, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + ), + }; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const firstRequest = + yield* Deferred.make>(); + const secondRequest = + yield* Deferred.make>(); + const requestCount = yield* Ref.make(0); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") { + const count = yield* Ref.updateAndGet(requestCount, (current) => current + 1); + yield* Deferred.succeed(count === 1 ? firstRequest : secondRequest, event).pipe( + Effect.ignore, + ); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ threadId, input: "first prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstOpened = yield* Deferred.await(firstRequest).pipe(Effect.timeout("5 seconds")); + const secondFiber = yield* adapter + .sendTurn({ threadId, input: "steer prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(twoPromptsActive).pipe(Effect.timeout("5 seconds")); + assert.equal(yield* turnActivity.activeCount, 1); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(firstOpened.requestId)), + "accept", + ); + const firstResult = yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + assert.isFalse(yield* Deferred.isDone(turnCompleted)); + assert.equal(yield* turnActivity.activeCount, 1); + const secondOpened = yield* Deferred.await(secondRequest).pipe(Effect.timeout("5 seconds")); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(secondOpened.requestId)), + "accept", + ); + const secondResult = yield* Fiber.join(secondFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.equal(String(firstResult.turnId), String(secondResult.turnId)); + assert.lengthOf( + events.filter( + (event) => event.type === "turn.started" && String(event.threadId) === String(threadId), + ), + 1, + ); + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles the prompt slot when a queued steer fiber is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-queued-steer-fiber-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const baseActivity = yield* makeKimiTurnActivity; + const activityMarks = yield* Ref.make(0); + const twoPromptsActive = yield* Deferred.make(); + const turnActivity: KimiTurnActivity = { + ...baseActivity, + markActive: (activeThreadId) => + baseActivity + .markActive(activeThreadId) + .pipe( + Effect.andThen( + Ref.updateAndGet(activityMarks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Deferred.succeed(twoPromptsActive, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + ), + }; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const firstRequest = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") { + yield* Deferred.succeed(firstRequest, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ threadId, input: "first prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstOpened = yield* Deferred.await(firstRequest).pipe(Effect.timeout("5 seconds")); + const steerFiber = yield* adapter + .sendTurn({ threadId, input: "steer prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(twoPromptsActive).pipe(Effect.timeout("5 seconds")); + + // The steer is prepared (slot raised) but queued behind the running + // prompt's permit. Dropping its fiber here used to leak the slot, so + // the merged turn could never settle and probe deferral stuck forever. + // Let the steer preparation finish and queue on the prompt permit + // before dropping its fiber. + for (let yieldAttempt = 0; yieldAttempt < 64; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + // Forked interrupt: it must not block the test if the permit + // acquisition itself is not interruptible. + yield* Fiber.interrupt(steerFiber).pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* adapter + .respondToRequest(threadId, ApprovalRequestId.make(String(firstOpened.requestId)), "accept") + .pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + yield* Fiber.await(steerFiber).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not run a queued steer after the active turn is interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-queued-steer-after-interrupt"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-queued-steer-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_STEER_PREPARED_MARKER: "1", + }), + ); + const baseActivity = yield* makeKimiTurnActivity; + const activityMarks = yield* Ref.make(0); + const twoPromptsActive = yield* Deferred.make(); + const turnActivity: KimiTurnActivity = { + ...baseActivity, + markActive: (activeThreadId) => + baseActivity + .markActive(activeThreadId) + .pipe( + Effect.andThen( + Ref.updateAndGet(activityMarks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 + ? Deferred.succeed(twoPromptsActive, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + ), + }; + const steerPrepared = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnActivity, + nativeEventLogger: { + filePath: "memory://kimi-queued-steer-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("steer-prepared") + ? Deferred.succeed(steerPrepared, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + const requestOpened = yield* Deferred.make(); + const turnStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, undefined).pipe(Effect.asVoid) + : event.type === "turn.started" && event.turnId !== undefined + ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ threadId, input: "active prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(requestOpened).pipe(Effect.timeout("5 seconds")); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("5 seconds")); + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "queued steer", + attachments: [], + modelSelection: { + instanceId: KIMI_INSTANCE, + model: "gpt-5.4", + options: [{ id: "reasoning", value: "high" }], + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(twoPromptsActive).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(steerPrepared).pipe(Effect.timeout("5 seconds")); + for (let attempt = 0; attempt < 16; attempt += 1) { + yield* Effect.yieldNow; + } + + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Fiber.await(steerFiber).pipe(Effect.timeout("5 seconds")); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.lengthOf( + requests.filter((request) => request.method === "session/prompt"), + 1, + ); + assert.strictEqual(yield* turnActivity.activeCount, 0); + const session = (yield* adapter.listSessions()).find((entry) => entry.threadId === threadId); + assert.strictEqual(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not let an interrupted prompt settlement consume a follow-up slot", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-interrupt-follow-up-race"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const firstRequest = + yield* Deferred.make>(); + const secondRequest = + yield* Deferred.make>(); + const requestCount = yield* Ref.make(0); + const firstTerminal = yield* Deferred.make(); + const secondTerminal = yield* Deferred.make(); + const terminalCount = yield* Ref.make(0); + const firstTurnStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + } + if (event.type === "request.opened") { + const count = yield* Ref.updateAndGet(requestCount, (current) => current + 1); + yield* Deferred.succeed(count === 1 ? firstRequest : secondRequest, event).pipe( + Effect.ignore, + ); + } + if (event.type === "turn.completed") { + const count = yield* Ref.updateAndGet(terminalCount, (current) => current + 1); + yield* Deferred.succeed(count === 1 ? firstTerminal : secondTerminal, undefined).pipe( + Effect.ignore, + ); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ threadId, input: "interrupt this prompt", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstRequest).pipe(Effect.timeout("5 seconds")); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("5 seconds")); + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(firstTerminal).pipe(Effect.timeout("5 seconds")); + + const followUpFiber = yield* adapter + .sendTurn({ threadId, input: "complete follow-up", attachments: [] }) + .pipe(Effect.forkChild); + const followUpOpened = yield* Deferred.await(secondRequest).pipe(Effect.timeout("5 seconds")); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(followUpOpened.requestId)), + "accept", + ); + const followUp = yield* Fiber.join(followUpFiber).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(secondTerminal).pipe(Effect.timeout("5 seconds")); + + const completed = terminalEvents(events, threadId); + assert.notEqual(String(firstTurnId), String(followUp.turnId)); + assert.deepEqual( + completed.map((event) => [String(event.turnId), event.payload.state]), + [ + [String(firstTurnId), "cancelled"], + [String(followUp.turnId), "completed"], + ], + ); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("drops late output and duplicate settlement after interrupt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-late-output-after-interrupt"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + }), + ); + const lateNativeUpdate = yield* Deferred.make(); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { + turnActivity, + nativeEventLogger: { + filePath: "memory://kimi-cancelled-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("late after cancel") + ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + const events: ProviderRuntimeEvent[] = []; + const requestOpened = + yield* Deferred.make>(); + const turnStarted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "request.opened" + ? Deferred.succeed(requestOpened, event).pipe(Effect.asVoid) + : event.type === "turn.started" && event.turnId !== undefined + ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "cancel before late output", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(requestOpened).pipe(Effect.timeout("5 seconds")); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("5 seconds")); + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("5 seconds")); + for (let attempt = 0; attempt < 8; attempt += 1) { + yield* Effect.yieldNow; + } + + const completed = terminalEvents(events, threadId); + const cancelledIndex = events.findIndex( + (event) => + event.type === "turn.completed" && + String(event.turnId) === String(turnId) && + event.payload.state === "cancelled", + ); + const outputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + const outputAfterCancel = events + .slice(cancelledIndex + 1) + .filter( + (event) => String(event.threadId) === String(threadId) && outputTypes.has(event.type), + ); + + assert.lengthOf(completed, 1); + assert.equal(completed[0]?.payload.state, "cancelled"); + assert.deepEqual(outputAfterCancel, []); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns activity to idle after prompt failure", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-prompt-failure-idle"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EXIT_ON_PROMPT: "1" }), + ); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const events: ProviderRuntimeEvent[] = []; + const failed = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => events.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" && event.payload.state === "failed" + ? Deferred.succeed(failed, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const error = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "fail prompt", attachments: [] }), + ); + yield* Deferred.await(failed).pipe(Effect.timeout("5 seconds")); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.lengthOf(terminalEvents(events, threadId), 1); + assert.equal(terminalEvents(events, threadId)[0]?.payload.state, "failed"); + assert.equal(yield* turnActivity.activeCount, 0); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("returns activity to idle when a running session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-stop-session-idle"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const turnActivity = yield* makeKimiTurnActivity; + const adapter = yield* makeTestAdapter(wrapperPath, { turnActivity }); + const requestOpened = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, undefined).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "stop active session", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(requestOpened).pipe(Effect.timeout("5 seconds")); + assert.equal(yield* turnActivity.activeCount, 1); + + yield* adapter.stopSession(threadId); + assert.equal(yield* turnActivity.activeCount, 0); + assert.isFalse(yield* adapter.hasSession(threadId)); + + yield* Fiber.interrupt(sendTurnFiber); + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("applies mode then model then refreshed thinking before prompt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-pre-prompt-order"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-pre-prompt-order-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* startTestSession(adapter, threadId); + const initialRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + yield* adapter.sendTurn({ + threadId, + input: "ordered configuration", + attachments: [], + interactionMode: "plan", + modelSelection: { + instanceId: KIMI_INSTANCE, + model: "gpt-5.4", + options: [{ id: "reasoning", value: "high" }], + }, + }); + const requests = (yield* Effect.promise(() => readJsonLines(requestLogPath))).slice( + initialRequests.length, + ); + const operations = requests.flatMap((request) => { + if (request.method === "session/prompt") { + return ["prompt"]; + } + if (request.method !== "session/set_config_option") { + return []; + } + const params = request.params as Record | undefined; + return [`${String(params?.configId)}:${String(params?.value)}`]; + }); + + assert.deepEqual(operations, ["mode:plan", "model:gpt-5.4", "reasoning:high", "prompt"]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("executes agent terminal commands through the ACP client terminal", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-terminal-execution"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-terminal-exec-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - free-form mock agent fixture config. + T3_ACP_TERMINAL_COMMAND: JSON.stringify({ + command: mockAgentCommand, + args: [ + "-e", + "process.stdout.write('terminal says ' + process.env.T3_MOCK_TERMINAL_ENV);process.exit(5)", + ], + }), + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* startTestSession(adapter, threadId); + yield* adapter.sendTurn({ threadId, input: "run the build", attachments: [] }); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + + // The chat session's initialize (the one followed by session/new) must + // advertise the terminal capability. Probes may log their own + // initialize with terminal: false; those must not flip either way. + const sessionNewIndex = requests.findIndex((request) => request.method === "session/new"); + assert.isAtLeast(sessionNewIndex, 0); + const sessionInitialize = requests + .slice(0, sessionNewIndex) + .toReversed() + .find((request) => request.method === "initialize"); + const initializeParams = sessionInitialize?.params as + | { clientCapabilities?: { terminal?: boolean } } + | undefined; + assert.strictEqual(initializeParams?.clientCapabilities?.terminal, true); + + assert.isUndefined(requests.find((request) => request.method === "mock/terminal_error")); + const result = requests.find((request) => request.method === "mock/terminal_result"); + const params = result?.params as { + created: { terminalId: string }; + waitResult: Record; + outputResult: { output: string; truncated: boolean; exitStatus?: { exitCode?: number } }; + }; + assert.isString(params.created.terminalId); + // The wait_for_exit response must carry exitCode at the TOP level and + // never a nested exitStatus (Kimi reads the nested shape as exit -1). + assert.strictEqual(params.waitResult.exitCode, 5); + assert.notProperty(params.waitResult, "exitStatus"); + assert.include(params.outputResult.output, "terminal says from-mock-agent"); + assert.isFalse(params.outputResult.truncated); + assert.strictEqual(params.outputResult.exitStatus?.exitCode, 5); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("interrupt kills the terminals blocking the agent in wait_for_exit", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-interrupt-terminal-wait"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-terminal-interrupt-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - free-form mock agent fixture config. + T3_ACP_TERMINAL_COMMAND: JSON.stringify({ + command: mockAgentCommand, + // No arrow `=>` here: the Windows wrapper passes this JSON through + // cmd's `set`, where `>` would be parsed as a redirection. + args: ["-e", "setInterval(function () {}, 1000)"], + cancelAfterWait: true, + }), + }), + ); + const terminalFinished = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + // The adapter drops post-cancel session updates from the event stream, + // but the native event log still observes them: the mock agent's + // "terminal-finished" update is the deterministic signal that it + // logged its terminal results after the kill. + nativeEventLogger: { + filePath: "memory://kimi-terminal-interrupt-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("terminal-finished") + ? Deferred.succeed(terminalFinished, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + const events: ProviderRuntimeEvent[] = []; + const terminalCreated = yield* Deferred.make(); + const turnStarted = yield* Deferred.make(); + const firstTurnCompleted = yield* Deferred.make(); + const secondTurnCompleted = yield* Deferred.make(); + const completedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "content.delta" && String(event.threadId) === String(threadId)) { + yield* Deferred.succeed(terminalCreated, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(turnStarted, event.turnId).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + const count = yield* Ref.updateAndGet(completedCount, (current) => current + 1); + yield* Deferred.succeed( + count === 1 ? firstTurnCompleted : secondTurnCompleted, + undefined, + ).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "serve forever", attachments: [] }) + .pipe(Effect.forkChild); + // The mock agent notifies after terminal/create, right before parking in + // terminal/wait_for_exit on the never-exiting command. + yield* Deferred.await(terminalCreated).pipe(Effect.timeout("5 seconds")); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("5 seconds")); + + // Without killing the session's terminals this deadlocks: the agent + // waits on wait_for_exit and cannot process session/cancel. + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("5 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(firstTurnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.deepEqual( + terminalEvents(events, threadId).map((event) => [ + String(event.turnId), + event.payload.state, + ]), + [[String(turnId), "cancelled"]], + ); + + // The session survives the interrupt: a follow-up turn on the same + // session prompts the agent and completes normally. + yield* adapter.sendTurn({ threadId, input: "follow up", attachments: [] }); + yield* Deferred.await(secondTurnCompleted).pipe(Effect.timeout("5 seconds")); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["cancelled", "completed"], + ); + + // The killed terminal stayed readable through output and release: the + // mock agent signals "terminal-finished" only after logging its + // terminal results, and the interrupted wait_for_exit reported the + // SIGTERM kill. + yield* Deferred.await(terminalFinished).pipe(Effect.timeout("5 seconds")); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isUndefined(requests.find((request) => request.method === "mock/terminal_error")); + const result = requests.find((request) => request.method === "mock/terminal_result"); + const params = result?.params as { + waitResult: Record; + outputResult: { exitStatus?: { signal?: string } }; + }; + assert.strictEqual(params.waitResult.signal, "SIGTERM"); + assert.notProperty(params.waitResult, "exitStatus"); + assert.strictEqual(params.outputResult.exitStatus?.signal, "SIGTERM"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "gates the plan decision on the native approval card and re-syncs the mode on the next build turn", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-plan-flow-native-gate"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-plan-flow-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_PLAN_FLOW: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const cardDeferreds = [ + yield* Deferred.make>(), + yield* Deferred.make>(), + yield* Deferred.make>(), + ]; + const turnDeferreds = [ + yield* Deferred.make(), + yield* Deferred.make(), + yield* Deferred.make(), + ]; + const openedCount = yield* Ref.make(0); + const completedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") { + const count = yield* Ref.updateAndGet(openedCount, (current) => current + 1); + const deferred = cardDeferreds[count - 1]; + if (deferred) { + yield* Deferred.succeed(deferred, event).pipe(Effect.ignore); + } + } + if (event.type === "turn.completed") { + const count = yield* Ref.updateAndGet(completedCount, (current) => current + 1); + const deferred = turnDeferreds[count - 1]; + if (deferred) { + yield* Deferred.succeed(deferred, undefined).pipe(Effect.ignore); + } + } + }), + ).pipe(Effect.forkChild); + + const modeSelectionsFromLog = async () => + (await readJsonLines(requestLogPath)).flatMap((request) => { + if (request.method !== "session/set_config_option") { + return []; + } + const params = request.params as Record | undefined; + return params?.configId === "mode" ? [String(params.value)] : []; + }); + + yield* startTestSession(adapter, threadId); + const firstFiber = yield* adapter + .sendTurn({ + threadId, + input: "plan the work", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.forkChild); + + // The plan decision is a user decision: it parks on a normal approval + // card whose detail is the plan markdown, and never rides T3's + // proposed-plan flow. + const planCard = yield* Deferred.await(cardDeferreds[0]!).pipe(Effect.timeout("5 seconds")); + assert.equal( + planCard.payload.detail, + "# Plan: Mock landing page\n\n## Steps\n- write the plan\n- ship it", + ); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(planCard.requestId)), + "accept", + ); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnDeferreds[0]!).pipe(Effect.timeout("5 seconds")); + + // Approving answers the native plan_approve option, so kimi-cli leaves + // plan mode itself and the turn completes instead of cancelling. The + // mock mirrors the real CLI's retry-on-dismissal, so a cancelled + // answer would show up here as repeated ExitPlanMode requests. + const planPhaseRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.lengthOf( + planPhaseRequests.filter( + (request) => + request.method === "mock/permission_request" && + (request.params as { title?: string } | undefined)?.title === "ExitPlanMode", + ), + 1, + ); + const planResponse = planPhaseRequests.find( + (request) => request.method === "mock/permission_response", + ); + const planResponseParams = planResponse?.params as + | { result?: { outcome?: { outcome?: string; optionId?: string } } } + | undefined; + assert.deepEqual(planResponseParams?.result?.outcome, { + outcome: "selected", + optionId: "plan_approve", + }); + assert.lengthOf( + events.filter((event) => event.type === "turn.proposed.completed"), + 0, + ); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["completed"], + ); + + // The CLI left plan mode natively and silently while the composer + // still says Plan. Re-pushing plan is swallowed by the shared ACP + // runtime's own tracked mode, so this follow-up runs in the post-exit + // mode without any mode RPC (documented limitation; the next build + // turn re-syncs). + const secondFiber = yield* adapter + .sendTurn({ + threadId, + input: "plan the follow-up", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.forkChild); + const toolCard = yield* Deferred.await(cardDeferreds[1]!).pipe(Effect.timeout("5 seconds")); + assert.include(toolCard.payload.detail, "echo mock-approved-command"); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(toolCard.requestId)), + "accept", + ); + yield* Fiber.join(secondFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnDeferreds[1]!).pipe(Effect.timeout("5 seconds")); + assert.deepEqual(yield* Effect.promise(modeSelectionsFromLog), ["plan"]); + + // A build turn pushes the runtime-derived mode again, which re-syncs + // the tracked mode with the CLI's actual post-exit state. + const thirdFiber = yield* adapter + .sendTurn({ threadId, input: "keep building", attachments: [] }) + .pipe(Effect.forkChild); + const buildCard = yield* Deferred.await(cardDeferreds[2]!).pipe( + Effect.timeout("5 seconds"), + ); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(buildCard.requestId)), + "accept", + ); + yield* Fiber.join(thirdFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnDeferreds[2]!).pipe(Effect.timeout("5 seconds")); + + assert.deepEqual(yield* Effect.promise(modeSelectionsFromLog), ["plan", "default"]); + assert.lengthOf( + events.filter((event) => event.type === "turn.proposed.completed"), + 0, + ); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["completed", "completed", "completed"], + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("full-access still pauses for the plan decision, then auto-approves tool gates", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-full-access-plan-carve-out"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-plan-carve-out-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_PLAN_FLOW: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const planCardOpened = + yield* Deferred.make>(); + const firstTurnCompleted = yield* Deferred.make(); + const secondTurnCompleted = yield* Deferred.make(); + const completedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") { + yield* Deferred.succeed(planCardOpened, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + const count = yield* Ref.updateAndGet(completedCount, (current) => current + 1); + yield* Deferred.succeed( + count === 1 ? firstTurnCompleted : secondTurnCompleted, + undefined, + ).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId, "full-access"); + const firstFiber = yield* adapter + .sendTurn({ + threadId, + input: "plan the work", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.forkChild); + + // Even under full access the plan decision is a user decision: it parks + // on an approval card and is never auto-approved. + const planCard = yield* Deferred.await(planCardOpened).pipe(Effect.timeout("5 seconds")); + assert.include(planCard.payload.detail, "# Plan: Mock landing page"); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(planCard.requestId)), + "accept", + ); + yield* Fiber.join(firstFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(firstTurnCompleted).pipe(Effect.timeout("5 seconds")); + + // The follow-up turn auto-approves the tool gate without any card. + yield* adapter.sendTurn({ threadId, input: "implement it", attachments: [] }); + yield* Deferred.await(secondTurnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf( + events.filter((event) => event.type === "request.opened"), + 1, + ); + assert.lengthOf( + events.filter((event) => event.type === "turn.proposed.completed"), + 0, + ); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["completed", "completed"], + ); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResponses = requests + .filter((request) => request.method === "mock/permission_response") + .map( + (request) => + (request.params as { result?: { outcome?: { outcome?: string; optionId?: string } } }) + .result?.outcome, + ); + assert.deepEqual(permissionResponses, [ + { outcome: "selected", optionId: "plan_approve" }, + { outcome: "selected", optionId: "approve_always" }, + ]); + // Session start binds yolo, the plan turn switches to plan, and the + // follow-up turn re-pushes the runtime mode because the CLI left plan + // natively on approval. + const modeSelections = requests.flatMap((request) => { + if (request.method !== "session/set_config_option") { + return []; + } + const params = request.params as Record | undefined; + return params?.configId === "mode" ? [String(params.value)] : []; + }); + assert.deepEqual(modeSelections, ["yolo", "plan", "yolo"]); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("re-opens the plan card on revise, then completes on approval", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-plan-flow-revise"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-plan-revise-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_PLAN_FLOW: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const firstCardOpened = + yield* Deferred.make>(); + const secondCardOpened = + yield* Deferred.make>(); + const openedCount = yield* Ref.make(0); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (event.type === "request.opened") { + const count = yield* Ref.updateAndGet(openedCount, (current) => current + 1); + yield* Deferred.succeed(count === 1 ? firstCardOpened : secondCardOpened, event).pipe( + Effect.ignore, + ); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const turnFiber = yield* adapter + .sendTurn({ + threadId, + input: "plan the work", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.forkChild); + + // Declining picks the revise option, so the CLI stays in plan mode and + // presents the plan again as a fresh card. + const firstCard = yield* Deferred.await(firstCardOpened).pipe(Effect.timeout("5 seconds")); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(firstCard.requestId)), + "decline", + ); + const secondCard = yield* Deferred.await(secondCardOpened).pipe(Effect.timeout("5 seconds")); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(secondCard.requestId)), + "accept", + ); + yield* Fiber.join(turnFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.lengthOf( + requests.filter( + (request) => + request.method === "mock/permission_request" && + (request.params as { title?: string } | undefined)?.title === "ExitPlanMode", + ), + 2, + ); + const permissionResponses = requests + .filter((request) => request.method === "mock/permission_response") + .map( + (request) => + (request.params as { result?: { outcome?: { outcome?: string; optionId?: string } } }) + .result?.outcome, + ); + assert.deepEqual(permissionResponses, [ + { outcome: "selected", optionId: "plan_revise" }, + { outcome: "selected", optionId: "plan_approve" }, + ]); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("auto-approves tool gates under full-access while the native mode is plan", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-full-access-plan-tool-gate"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const turnCompleted = 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(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId, "full-access"); + // Plan interaction keeps the native mode on plan; a tool-gate request + // arriving in that state must still be auto-approved for full access. + yield* adapter.sendTurn({ + threadId, + input: "run a command", + attachments: [], + interactionMode: "plan", + }); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf( + events.filter((event) => event.type === "request.opened"), + 0, + ); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["completed"], + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancels permission requests that arrive after the turn was interrupted", () => + Effect.gen(function* () { + const threadId = ThreadId.make("kimi-permission-after-cancel"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "kimi-permission-after-cancel-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockKimiWrapper({ + T3_ACP_PERMISSION_AFTER_CANCEL: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const latePermissionRequest = yield* Deferred.make(); + const latePermissionResolved = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + // Post-cancel session updates never reach the runtime event stream by + // design; the native log still observes the late request itself and + // the mock's post-response marker. + nativeEventLogger: { + filePath: "memory://kimi-permission-after-cancel-native-events", + write: (record: unknown) => { + const serialized = JSON.stringify(record); + return serialized.includes("session/request_permission") + ? Deferred.succeed(latePermissionRequest, undefined).pipe(Effect.asVoid) + : serialized.includes("permission-resolved") + ? Deferred.succeed(latePermissionResolved, undefined).pipe(Effect.asVoid) + : Effect.void; + }, + close: () => Effect.void, + }, + }); + const events: ProviderRuntimeEvent[] = []; + const promptParked = yield* Deferred.make(); + const turnStarted = yield* Deferred.make(); + const firstTurnCompleted = yield* Deferred.make(); + const secondTurnCompleted = yield* Deferred.make(); + const completedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "content.delta" && String(event.threadId) === String(threadId)) { + yield* Deferred.succeed(promptParked, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(turnStarted, event.turnId).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + const count = yield* Ref.updateAndGet(completedCount, (current) => current + 1); + yield* Deferred.succeed( + count === 1 ? firstTurnCompleted : secondTurnCompleted, + undefined, + ).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* startTestSession(adapter, threadId); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hold until stopped", attachments: [] }) + .pipe(Effect.forkChild); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("5 seconds")); + // The mock notifies once it parked the prompt, so the interrupt lands + // while the prompt is genuinely in flight. + yield* Deferred.await(promptParked).pipe(Effect.timeout("5 seconds")); + + // The mock answers session/cancel by firing a late permission request + // before completing the prompt. The interrupted-turn gate must cancel + // it without ever emitting request.opened. + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(latePermissionRequest).pipe(Effect.timeout("5 seconds")); + // The mock only completes the prompt after the permission response + // arrives, so the joined fiber proves the gate answered cancelled. + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(firstTurnCompleted).pipe(Effect.timeout("5 seconds")); + + assert.lengthOf( + events.filter((event) => event.type === "request.opened"), + 0, + ); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => [ + String(event.turnId), + event.payload.state, + ]), + [[String(turnId), "cancelled"]], + ); + // Wait for the mock's post-response marker: the RPC layer unwinds the + // prompt client-side on cancel, so prompt settlement does not prove the + // mock saw the gate's cancelled answer. + yield* Deferred.await(latePermissionResolved).pipe(Effect.timeout("5 seconds")); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const lateResponse = requests.find( + (request) => request.method === "mock/permission_response", + ); + const lateResponseParams = lateResponse?.params as + | { result?: { outcome?: { outcome?: string } } } + | undefined; + assert.equal(lateResponseParams?.result?.outcome?.outcome, "cancelled"); + + // The session survives: a follow-up turn prompts and completes normally. + yield* adapter + .sendTurn({ threadId, input: "follow up", attachments: [] }) + .pipe(Effect.timeout("5 seconds")); + yield* Deferred.await(secondTurnCompleted).pipe(Effect.timeout("5 seconds")); + assert.deepEqual( + terminalEvents(events, threadId).map((event) => event.payload.state), + ["cancelled", "completed"], + ); + + yield* Fiber.interrupt(eventsFiber); + 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..1d676ea533f6 --- /dev/null +++ b/apps/server/src/provider/Layers/KimiAdapter.ts @@ -0,0 +1,1599 @@ +import { + ApprovalRequestId, + type KimiSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { getProviderOptionStringSelectionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + advertisedKimiModelIdsFromSessionSetup, + applyKimiAcpModeSelection, + applyKimiAcpModelSelection, + applyKimiAcpThinkingSelection, + classifyKimiPermissionRequest, + currentKimiModeIdFromConfigOptions, + currentKimiModeIdFromSessionSetup, + currentKimiModelIdFromConfigOptions, + currentKimiModelIdFromSessionSetup, + findKimiThinkingConfigOption, + kimiConfigOptionsFromSessionNotification, + kimiPermissionRequestDetail, + kimiSessionHasModelConfigOption, + resolveKimiAcpModeId, + shouldKimiAdapterAutoApprove, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, + type KimiTurnActivity, +} from "../acp/KimiAcpSupport.ts"; +import { + makeKimiAcpTerminalManager, + type KimiAcpTerminalManager, +} from "../acp/KimiAcpTerminalSupport.ts"; +import { type KimiAdapterShape } from "../Services/KimiAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("kimi"); +const KIMI_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface KimiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + readonly turnActivity?: KimiTurnActivity; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +interface KimiSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly terminals: KimiAcpTerminalManager; + readonly promptSemaphore: Semaphore.Semaphore; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly configOptionsRef: Ref.Ref>; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + currentModeId: string | undefined; + currentModelId: string | undefined; + /** Exact model ids the agent advertised at session setup. */ + readonly advertisedModelIds: ReadonlyArray | undefined; + readonly hasModelConfigOption: boolean; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: KimiSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: KimiSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: KimiSessionContext): TurnId | undefined => ctx.activeTurnId; + +function parseKimiResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== KIMI_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + return response?.stopReason ?? null; +} + +export function clearSettledKimiInterruptedTurnIds(interruptedTurnIds: Set): void { + interruptedTurnIds.clear(); +} + +export function kimiPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export function makeKimiAdapter(kimiSettings: KimiSettings, options?: KimiAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("kimi"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Kimi runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const setPromptsInFlight = (ctx: KimiSessionContext, count: number) => + Effect.gen(function* () { + ctx.promptsInFlight = Math.max(0, count); + if (!options?.turnActivity) { + return; + } + yield* ctx.promptsInFlight > 0 + ? options.turnActivity.markActive(ctx.threadId) + : options.turnActivity.markIdle(ctx.threadId); + }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = kimiPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + yield* setPromptsInFlight(liveCtx, 0); + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + yield* setPromptsInFlight(liveCtx, remainingPrompts); + return; + } + yield* setPromptsInFlight(liveCtx, remainingPrompts); + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + if (!options?.settleAllPrompts && (shouldEmitFailedTurn || shouldEmitCompletedTurn)) { + clearSettledKimiInterruptedTurnIds(liveCtx.interruptedTurnIds); + } + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Kimi notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: KimiSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: KimiSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* setPromptsInFlight(ctx, 0); + yield* ctx.terminals.shutdown; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: KimiAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const kimiModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const configOptionsRef = yield* Ref.make< + ReadonlyArray + >([]); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseKimiResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeKimiAcpRuntime({ + kimiSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + // Kimi executes shell commands through the ACP client terminal; + // chat sessions advertise it and register the handlers below. + terminal: true, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to start the Kimi ACP session.", + cause, + }), + ), + ); + const terminals = yield* makeKimiAcpTerminalManager({ childProcessSpawner }); + const started = yield* Effect.gen(function* () { + yield* acp.handleSessionUpdate((notification) => { + const configOptions = kimiConfigOptionsFromSessionNotification(notification); + return configOptions ? Ref.set(configOptionsRef, configOptions) : Effect.void; + }); + yield* acp.handleCreateTerminal(terminals.handleCreateTerminal); + yield* acp.handleTerminalOutput(terminals.handleTerminalOutput); + yield* acp.handleTerminalWaitForExit(terminals.handleTerminalWaitForExit); + yield* acp.handleTerminalKill(terminals.handleTerminalKill); + yield* acp.handleTerminalRelease(terminals.handleTerminalRelease); + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + const sessionCtx = sessions.get(input.threadId); + const callbackTurnId = + sessionCtx !== undefined ? resolveCallbackTurnId(sessionCtx) : undefined; + // A permission request that arrives after the turn was stopped + // (Kimi has not processed session/cancel yet) must never open + // a fresh approval card on the dead turn; cancel it outright. + if ( + sessionCtx === undefined || + sessionCtx.stopped || + callbackTurnId === undefined || + sessionCtx.promptsInFlight <= 0 || + sessionCtx.interruptedTurnIds.has(callbackTurnId) + ) { + return { outcome: { outcome: "cancelled" as const } }; + } + const requestKind = classifyKimiPermissionRequest(params); + // Plan decisions (ExitPlanMode) are user decisions, so they + // fall through to the regular approval card below: answering + // cancelled reads as "dialog dismissed" to kimi-cli and makes + // it retry ExitPlanMode in a loop, while answering + // plan_approve lets the CLI leave plan mode and implement in + // the same turn. T3's proposed-plan flow is not used for Kimi. + if ( + shouldKimiAdapterAutoApprove({ + runtimeMode: input.runtimeMode, + requestKind, + }) + ) { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = callbackTurnId; + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + kimiPermissionRequestDetail(params) ?? + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Kimi ACP callback.", + cause, + }), + ), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const boundModeId = yield* applyKimiAcpModeSelection({ + runtime: acp, + currentModeId: currentKimiModeIdFromSessionSetup(started.sessionSetupResult), + requestedModeId: resolveKimiAcpModeId({ runtimeMode: input.runtimeMode }), + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + }); + const requestedStartModelId = kimiModelSelection?.model + ? resolveKimiAcpBaseModelId(kimiModelSelection.model) + : undefined; + const advertisedModelIds = advertisedKimiModelIdsFromSessionSetup( + started.sessionSetupResult, + ); + const hasModelConfigOption = kimiSessionHasModelConfigOption(started.sessionSetupResult); + const boundModelId = yield* applyKimiAcpModelSelection({ + runtime: acp, + currentModelId: currentKimiModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: requestedStartModelId, + advertisedModelIds, + hasModelConfigOption, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + const initialConfigOptions = yield* acp.getConfigOptions; + const initialThinkingConfig = findKimiThinkingConfigOption(initialConfigOptions); + const initialThinking = yield* applyKimiAcpThinkingSelection({ + runtime: acp, + configOptions: initialConfigOptions, + requestedValue: initialThinkingConfig + ? getProviderOptionStringSelectionValue( + kimiModelSelection?.options, + initialThinkingConfig.id, + ) + : undefined, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + yield* Ref.set(configOptionsRef, initialThinking.configOptions); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(boundModelId ? { model: resolveKimiAcpBaseModelId(boundModelId) } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: KIMI_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + const promptSemaphore = yield* Semaphore.make(1); + + const ctx: KimiSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + terminals, + promptSemaphore, + notificationFiber: undefined, + pendingApprovals, + configOptionsRef, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModeId: boundModeId, + currentModelId: boundModelId, + advertisedModelIds, + hasModelConfigOption, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Kimi runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Kimi ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: KimiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent + // folds the new prompt into the ongoing work, so the active turn + // id is reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; decremented on + // preparation failure here, and after the prompt below otherwise. + yield* setPromptsInFlight(ctx, ctx.promptsInFlight + 1); + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const observedConfigOptions = yield* Ref.get(ctx.configOptionsRef); + const observedModeId = + currentKimiModeIdFromConfigOptions(observedConfigOptions) ?? ctx.currentModeId; + const requestedModeId = resolveKimiAcpModeId({ + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + }); + const modeChanged = observedModeId !== requestedModeId; + const currentModeId = yield* applyKimiAcpModeSelection({ + runtime: ctx.acp, + currentModeId: observedModeId, + requestedModeId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + }); + ctx.currentModeId = currentModeId; + + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModelId = turnModelSelection?.model + ? resolveKimiAcpBaseModelId(turnModelSelection.model) + : undefined; + const observedModelId = + currentKimiModelIdFromConfigOptions(observedConfigOptions) ?? ctx.currentModelId; + const modelChanged = + requestedTurnModelId !== undefined && + requestedTurnModelId !== + (observedModelId ? resolveKimiAcpBaseModelId(observedModelId) : undefined); + const currentModelId = yield* applyKimiAcpModelSelection({ + runtime: ctx.acp, + currentModelId: observedModelId, + requestedModelId: requestedTurnModelId, + advertisedModelIds: ctx.advertisedModelIds, + hasModelConfigOption: ctx.hasModelConfigOption, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + ctx.currentModelId = currentModelId; + + const refreshedConfigOptions = + modeChanged || modelChanged + ? yield* ctx.acp.getConfigOptions + : observedConfigOptions; + const thinkingConfig = findKimiThinkingConfigOption(refreshedConfigOptions); + const thinking = yield* applyKimiAcpThinkingSelection({ + runtime: ctx.acp, + configOptions: refreshedConfigOptions, + requestedValue: thinkingConfig + ? getProviderOptionStringSelectionValue( + turnModelSelection?.options, + thinkingConfig.id, + ) + : undefined, + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + yield* Ref.set(ctx.configOptionsRef, thinking.configOptions); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Failed to read a turn attachment.", + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const displayModel = currentModelId + ? resolveKimiAcpBaseModelId(currentModelId) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kimi prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + promptSemaphore: ctx.promptSemaphore, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Kimi prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + const runPrompt = Effect.gen(function* () { + const promptAdmitted = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if ( + ctx.acpSessionId !== prepared.acpSessionId || + ctx.interruptedTurnIds.has(prepared.turnId) || + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return false; + } + clearSettledKimiInterruptedTurnIds(ctx.interruptedTurnIds); + return true; + }), + ); + if (!promptAdmitted) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: (yield* requireSession(input.threadId)).session.resumeCursor, + }; + } + + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kimi session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Kimi session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + yield* setPromptsInFlight(ctx, remainingPrompts); + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + clearSettledKimiInterruptedTurnIds(ctx.interruptedTurnIds); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Kimi session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Kimi prompt request failed.", + }), + ); + }).pipe( + // Every path through this finalizer accounts for the prompt + // slot, so the queued-interrupt compensation below must no-op. + Effect.ensuring(Ref.set(promptSettled, true)), + Effect.catch(() => Effect.void), + ), + ), + ); + // The finalizer above only runs once runPrompt has started, which + // requires holding the permit. A fiber interrupted while still QUEUED + // on the semaphore (a steer waiting behind an active prompt when the + // client drops the request) would skip cleanup entirely, leaving + // promptsInFlight raised and the turn-activity tracker marked active, + // which defers provider probes until the session stops. + return yield* prepared.promptSemaphore.withPermit(runPrompt).pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + yield* Ref.set(promptSettled, true); + // With another prompt still in flight this only releases the + // slot; if the dropped steer was the last slot it settles the + // merged turn with an honest cancelled completion. + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + completedStopReason: "cancelled", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: KimiAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + // Kill the session's terminals before cancelling: Kimi may be + // parked in terminal/wait_for_exit for a never-exiting command, + // and it cannot process session/cancel (or answer the prompt) + // until that client request resolves. killAll keeps the terminal + // entries readable; only stopSession disposes them. + yield* Effect.ignore(ctx.terminals.killAll); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + yield* setPromptsInFlight(ctx, 0); + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest: KimiAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: KimiAdapterShape["respondToUserInput"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Kimi sessions do not issue user-input requests (request: ${requestId}).`, + }); + }); + + const readThread: KimiAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: KimiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Kimi ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: KimiAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: KimiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: KimiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: KimiAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies KimiAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/KimiProvider.test.ts b/apps/server/src/provider/Layers/KimiProvider.test.ts new file mode 100644 index 000000000000..4a39cd5bc7fc --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.test.ts @@ -0,0 +1,712 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + KimiSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; + +import { + resolveKimiDriverBinaryPath, + runKimiProbeWithActiveTurnDeferral, + stabilizeKimiProviderProbe, +} from "../Drivers/KimiDriver.ts"; +import { kimiModelStateFromSessionSetup, makeKimiTurnActivity } from "../acp/KimiAcpSupport.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { + buildInitialKimiProviderSnapshot, + buildKimiDiscoveredModelsFromSessionModelState, + buildKimiModelDiscoveryCacheKey, + buildKimiThinkingCapabilitiesFromConfigOptions, + checkKimiProviderStatus, + probeKimiProviderStatus, + type KimiAcpProbeResult, + type KimiProviderProbeOperations, + type KimiVersionProbeResult, +} from "./KimiProvider.ts"; + +const decodeKimiSettings = Schema.decodeSync(KimiSettings); +const discoveredModels = buildKimiDiscoveredModelsFromSessionModelState({ + currentModelId: "k3", + availableModels: [{ modelId: "k3", name: "K3" }], +}); +const dynamicModelSession = { + sessionId: "session-1", + configOptions: [ + { + id: "available-model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-code/k3", + 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" }, + { value: "moonshot-ai/kimi-k3", name: "kimi-k3" }, + { value: "moonshot-ai/kimi-k2.7-code-highspeed", name: "kimi-k2.7-code-highspeed" }, + { value: "moonshot-ai/kimi-k2.7-code", name: "kimi-k2.7-code" }, + { value: "moonshot-ai/kimi-k2.5", name: "kimi-k2.5" }, + { value: "moonshot-ai/kimi-k2.6", name: "kimi-k2.6" }, + ], + }, + ], +} satisfies EffectAcpSchema.NewSessionResponse; + +function thinkingConfigOptions( + values: ReadonlyArray, + currentValue: string, +): ReadonlyArray { + return [ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue, + options: values.map((value) => ({ value, name: value.toUpperCase() })), + }, + ]; +} + +function makeProbeOperations( + input: { + readonly version?: KimiVersionProbeResult; + readonly authentication?: KimiAcpProbeResult; + readonly discovery?: KimiAcpProbeResult>; + } = {}, +): KimiProviderProbeOperations { + return { + probeVersion: () => + Effect.succeed( + input.version ?? { + _tag: "success", + version: "0.37.2", + resolvedBinaryPath: "C:\\Users\\test\\.kimi-code\\bin\\kimi.exe", + }, + ), + probeAuthentication: () => + Effect.succeed(input.authentication ?? { _tag: "success", value: undefined }), + discoverModels: () => + Effect.succeed(input.discovery ?? { _tag: "success", value: discoveredModels }), + }; +} + +function stampTestProvider(snapshot: ServerProviderDraft): ServerProvider { + return { + ...snapshot, + instanceId: ProviderInstanceId.make("kimi"), + driver: ProviderDriverKind.make("kimi"), + }; +} + +describe("buildInitialKimiProviderSnapshot", () => { + it.effect("returns a disabled snapshot by default (opt-in like sibling ACP providers)", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot(decodeKimiSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialKimiProviderSnapshot( + decodeKimiSettings({ enabled: true }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Kimi"); + expect(snapshot.showInteractionModeToggle).toBe(true); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + }), + ); +}); + +it.layer(NodeServices.layer)("Kimi driver probe controls", (it) => { + it.effect("prefers explicit paths, then the official installer, then PATH", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-home-" }); + const fallbackHomeDirectory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-kimi-home-fallback-", + }); + const officialDirectory = path.join(homeDirectory, ".kimi-code", "bin"); + const officialBinaryPath = path.join(officialDirectory, "kimi.exe"); + yield* fs.makeDirectory(officialDirectory, { recursive: true }); + yield* fs.writeFileString(officialBinaryPath, "fixture"); + + const explicitBinaryPath = "C:\\tools\\custom-kimi.exe"; + expect( + yield* resolveKimiDriverBinaryPath( + { binaryPath: explicitBinaryPath }, + { homeDirectory }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")), + ).toBe(explicitBinaryPath); + expect( + yield* resolveKimiDriverBinaryPath({ binaryPath: "kimi" }, { homeDirectory }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + ), + ).toBe(officialBinaryPath); + expect( + yield* resolveKimiDriverBinaryPath( + { binaryPath: "kimi" }, + { homeDirectory: fallbackHomeDirectory }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")), + ).toBe("kimi"); + }), + ), + ); + + it.effect("atomically denies probe admission after a turn becomes active", () => + Effect.gen(function* () { + const turnActivity = yield* makeKimiTurnActivity; + const threadId = ThreadId.make("thread-probe-admission"); + + expect(yield* turnActivity.beginProbeIfIdle).toBe(true); + yield* turnActivity.markActive(threadId); + expect(yield* turnActivity.beginProbeIfIdle).toBe(false); + yield* turnActivity.endProbe; + yield* turnActivity.markIdle(threadId); + expect(yield* turnActivity.beginProbeIfIdle).toBe(true); + yield* turnActivity.endProbe; + }), + ); + + it.effect("defers a probe during an active turn and resumes when the turn settles", () => + Effect.gen(function* () { + const turnActivity = yield* makeKimiTurnActivity; + const threadId = ThreadId.make("thread-active"); + const probedSnapshot = stampTestProvider({ + ...(yield* buildInitialKimiProviderSnapshot(decodeKimiSettings({ enabled: true }))), + status: "ready", + auth: { status: "authenticated" }, + message: "probe completed", + }); + const probeStarted = yield* Deferred.make(); + yield* turnActivity.markActive(threadId); + + const probeFiber = yield* runKimiProbeWithActiveTurnDeferral({ + turnActivity, + probe: Deferred.succeed(probeStarted, undefined).pipe(Effect.as(probedSnapshot)), + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + + expect(yield* turnActivity.activeCount).toBe(1); + expect(yield* Deferred.isDone(probeStarted)).toBe(false); + yield* turnActivity.markIdle(threadId); + expect(yield* Fiber.join(probeFiber)).toBe(probedSnapshot); + expect(yield* Deferred.isDone(probeStarted)).toBe(true); + expect(yield* turnActivity.activeCount).toBe(0); + }), + ); +}); + +describe("buildKimiDiscoveredModelsFromSessionModelState", () => { + it("returns nothing for an absent or empty model state", () => { + expect(buildKimiDiscoveredModelsFromSessionModelState(undefined)).toEqual([]); + expect( + buildKimiDiscoveredModelsFromSessionModelState({ + currentModelId: "k3", + availableModels: [], + }), + ).toEqual([]); + }); + + it("builds every model advertised by the category-based config option", () => { + const models = buildKimiDiscoveredModelsFromSessionModelState( + kimiModelStateFromSessionSetup(dynamicModelSession), + ); + + expect(models.map((model) => [model.slug, model.name])).toEqual([ + ["kimi-for-coding", "K2.7 Coding"], + ["kimi-for-coding-highspeed", "K2.7 Coding Highspeed"], + ["k3", "K3"], + ["k3-256k", "K3-256k"], + ["moonshot-ai/kimi-k3", "kimi-k3"], + ["moonshot-ai/kimi-k2.7-code-highspeed", "kimi-k2.7-code-highspeed"], + ["moonshot-ai/kimi-k2.7-code", "kimi-k2.7-code"], + ["moonshot-ai/kimi-k2.5", "kimi-k2.5"], + ["moonshot-ai/kimi-k2.6", "kimi-k2.6"], + ]); + expect(models.find((model) => model.slug === "k3")?.isDefault).toBe(true); + }); + + it("builds model-specific thinking descriptors only from advertised values", () => { + const k3Capabilities = buildKimiThinkingCapabilitiesFromConfigOptions( + thinkingConfigOptions(["low", "high", "max"], "high"), + ); + const k27Capabilities = buildKimiThinkingCapabilitiesFromConfigOptions( + thinkingConfigOptions(["on", "high"], "high"), + ); + const configurableCapabilities = buildKimiThinkingCapabilitiesFromConfigOptions( + thinkingConfigOptions(["off", "on", "high"], "high"), + ); + + expect(k3Capabilities.optionDescriptors?.[0]).toMatchObject({ + id: "thinking", + label: "Thinking", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "LOW" }, + { id: "high", label: "HIGH", isDefault: true }, + { id: "max", label: "MAX" }, + ], + }); + expect( + k27Capabilities.optionDescriptors?.[0]?.type === "select" + ? k27Capabilities.optionDescriptors[0].options.map((option) => option.id) + : [], + ).toEqual(["on", "high"]); + expect( + configurableCapabilities.optionDescriptors?.[0]?.type === "select" + ? configurableCapabilities.optionDescriptors[0].options.map((option) => option.id) + : [], + ).toEqual(["off", "on", "high"]); + + const models = buildKimiDiscoveredModelsFromSessionModelState( + kimiModelStateFromSessionSetup(dynamicModelSession), + new Map([ + ["kimi-code/k3", k3Capabilities], + ["kimi-code/kimi-for-coding", k27Capabilities], + ]), + ); + expect(models.find((model) => model.slug === "k3")?.capabilities).toEqual(k3Capabilities); + expect(models.find((model) => model.slug === "kimi-for-coding")?.capabilities).toEqual( + k27Capabilities, + ); + }); + + it("collapses thinking variants onto their base model and marks the current default", () => { + const models = buildKimiDiscoveredModelsFromSessionModelState({ + currentModelId: "k3,thinking", + availableModels: [ + { modelId: "k3", name: "K3" }, + { modelId: "k3,thinking", name: "K3 (Thinking)" }, + { modelId: "kimi-for-coding", name: "Kimi K2.7 Code" }, + ], + }); + + expect(models.map((model) => model.slug)).toEqual(["k3", "kimi-for-coding"]); + expect(models[0]?.isDefault).toBe(true); + expect(models[1]?.isDefault).toBeUndefined(); + }); +}); + +it.layer(NodeServices.layer)("probeKimiProviderStatus", (it) => { + it.effect("classifies a first healthy probe and reuses its discovery cache", () => + Effect.gen(function* () { + const settings = decodeKimiSettings({ enabled: true }); + const first = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations(), + }, + ); + expect(first.classification).toEqual({ _tag: "healthy", modelSource: "discovery" }); + expect(first.snapshot.status).toBe("ready"); + expect(first.snapshot.auth.status).toBe("authenticated"); + expect(first.discoveryCache).toBeDefined(); + + const cache = first.discoveryCache; + if (!cache) { + throw new Error("healthy probe did not produce a discovery cache"); + } + const second = yield* probeKimiProviderStatus( + settings, + {}, + { + discoveryCache: cache, + operations: { + ...makeProbeOperations(), + discoverModels: () => Effect.die("discovery should not run on a cache hit"), + }, + }, + ); + expect(second.classification).toEqual({ _tag: "healthy", modelSource: "cache" }); + expect(second.snapshot.status).toBe("ready"); + expect(second.snapshot.models.map((model) => model.slug)).toEqual(["k3"]); + }), + ); + + it.effect("does not cache an empty model discovery result", () => + Effect.gen(function* () { + const settings = decodeKimiSettings({ enabled: true }); + const empty = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ discovery: { _tag: "success", value: [] } }), + }, + ); + + expect(empty.classification).toEqual({ _tag: "healthy", modelSource: "discovery" }); + expect(empty.discoveryCache).toBeUndefined(); + expect(empty.snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + + const retry = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations(), + }, + ); + expect(retry.classification).toEqual({ _tag: "healthy", modelSource: "discovery" }); + expect(retry.discoveryCache?.models).toEqual(discoveredModels); + }), + ); + + it.effect("rediscovers and merges custom models after settings change the cache key", () => + Effect.gen(function* () { + const first = yield* probeKimiProviderStatus( + decodeKimiSettings({ enabled: true, customModels: ["moonshot-ai/custom-one"] }), + {}, + { operations: makeProbeOperations() }, + ); + const discoveryCache = first.discoveryCache; + if (!discoveryCache) { + throw new Error("healthy probe did not produce a discovery cache"); + } + + const changed = yield* probeKimiProviderStatus( + decodeKimiSettings({ enabled: true, customModels: ["moonshot-ai/custom-two"] }), + {}, + { + discoveryCache, + operations: { + ...makeProbeOperations(), + probeAuthentication: () => Effect.die("stale cache must not be authenticated"), + }, + }, + ); + expect(changed.classification).toEqual({ _tag: "healthy", modelSource: "discovery" }); + expect(changed.snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "moonshot-ai/custom-two", + ]); + }), + ); + + it.effect("keys model discovery by version, resolved binary, home, and settings", () => + Effect.sync(() => { + const settings = decodeKimiSettings({ + binaryPath: "kimi", + homePath: "~/.kimi-code-one", + customModels: ["custom-one"], + }); + const base = buildKimiModelDiscoveryCacheKey({ + version: "0.37.2", + resolvedBinaryPath: "C:\\Kimi\\kimi.exe", + kimiSettings: settings, + }); + expect( + buildKimiModelDiscoveryCacheKey({ + version: "0.37.3", + resolvedBinaryPath: "C:\\Kimi\\kimi.exe", + kimiSettings: settings, + }), + ).not.toBe(base); + expect( + buildKimiModelDiscoveryCacheKey({ + version: "0.37.2", + resolvedBinaryPath: "D:\\Kimi\\kimi.exe", + kimiSettings: settings, + }), + ).not.toBe(base); + expect( + buildKimiModelDiscoveryCacheKey({ + version: "0.37.2", + resolvedBinaryPath: "C:\\Kimi\\kimi.exe", + kimiSettings: decodeKimiSettings({ + binaryPath: "kimi", + homePath: "~/.kimi-code-two", + customModels: ["custom-two"], + }), + }), + ).not.toBe(base); + }), + ); + + it.effect("keeps command-missing, non-zero, and auth-required outcomes distinct", () => + Effect.gen(function* () { + const settings = decodeKimiSettings({ enabled: true }); + const commandMissing = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ version: { _tag: "command-missing" } }), + }, + ); + expect(commandMissing.classification).toEqual({ _tag: "command-missing" }); + expect(commandMissing.snapshot.installed).toBe(false); + + const nonZero = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ + version: { _tag: "non-zero-exit", exitCode: 2, version: "0.37.2" }, + }), + }, + ); + expect(nonZero.classification).toEqual({ _tag: "non-zero-exit", exitCode: 2 }); + expect(nonZero.snapshot.installed).toBe(true); + expect(nonZero.snapshot.auth.status).toBe("unknown"); + + const authRequired = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ discovery: { _tag: "auth-required" } }), + }, + ); + expect(authRequired.classification).toEqual({ _tag: "auth-required" }); + expect(authRequired.snapshot.auth.status).toBe("unauthenticated"); + + const acpFailure = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ + discovery: { _tag: "failure", errorTag: "AcpProtocolParseError" }, + }), + }, + ); + expect(acpFailure.classification).toEqual({ + _tag: "acp-failure", + stage: "discovery", + errorTag: "AcpProtocolParseError", + }); + expect(acpFailure.snapshot.status).toBe("error"); + }), + ); + + it.effect("uses a non-destructive warning for a first transient timeout", () => + Effect.gen(function* () { + const result = yield* probeKimiProviderStatus( + decodeKimiSettings({ enabled: true }), + {}, + { + operations: makeProbeOperations({ + version: { _tag: "transient-timeout", timeoutMs: 10_000 }, + }), + }, + ); + expect(result.classification).toEqual({ + _tag: "transient-timeout", + stage: "version", + timeoutMs: 10_000, + }); + expect(result.snapshot.status).toBe("warning"); + expect(result.snapshot.auth.status).toBe("unknown"); + }), + ); + + it.effect("retains a healthy snapshot across three transient refresh cycles", () => + Effect.gen(function* () { + const settings = decodeKimiSettings({ enabled: true }); + const healthy = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations(), + }, + ); + const lastKnownGoodRef = yield* Ref.make(null); + yield* stabilizeKimiProviderProbe(lastKnownGoodRef, { + ...healthy, + snapshot: stampTestProvider(healthy.snapshot), + }); + + const transientCycles: ReadonlyArray = [ + { _tag: "transient-timeout", timeoutMs: 10_000 }, + { _tag: "transient-process-failure", errorTag: "AcpTransportError" }, + { _tag: "transient-timeout", timeoutMs: 10_000 }, + ]; + for (const version of transientCycles) { + const transient = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations({ version }), + }, + ); + const snapshot = yield* stabilizeKimiProviderProbe(lastKnownGoodRef, { + ...transient, + snapshot: stampTestProvider(transient.snapshot), + }); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + } + }), + ); + + it.effect("does not mask auth loss with the last healthy snapshot", () => + Effect.gen(function* () { + const settings = decodeKimiSettings({ enabled: true }); + const lastKnownGoodRef = yield* Ref.make(null); + const healthy = yield* probeKimiProviderStatus( + settings, + {}, + { + operations: makeProbeOperations(), + }, + ); + yield* stabilizeKimiProviderProbe(lastKnownGoodRef, { + ...healthy, + snapshot: stampTestProvider(healthy.snapshot), + }); + const discoveryCache = healthy.discoveryCache; + if (!discoveryCache) { + throw new Error("healthy probe did not produce a discovery cache"); + } + const authRequired = yield* probeKimiProviderStatus( + settings, + {}, + { + discoveryCache, + operations: makeProbeOperations({ authentication: { _tag: "auth-required" } }), + }, + ); + const snapshot = yield* stabilizeKimiProviderProbe(lastKnownGoodRef, { + ...authRequired, + snapshot: stampTestProvider(authRequired.snapshot), + }); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(yield* Ref.get(lastKnownGoodRef)).toBeNull(); + }), + ); +}); + +it.layer(NodeServices.layer)("checkKimiProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkKimiProviderStatus( + decodeKimiSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/kimi-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const secretStderr = "broken kimi install: secret-token-value"; + const platform = yield* HostProcessPlatform; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-version-" }); + const kimiPath = path.join(dir, platform === "win32" ? "kimi.cmd" : "kimi"); + yield* fs.writeFileString( + kimiPath, + platform === "win32" + ? ["@echo off", `echo ${secretStderr} 1>&2`, "exit /b 2"].join("\r\n") + : ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2"].join("\n"), + ); + if (platform !== "win32") { + yield* fs.chmod(kimiPath, 0o755); + } + + return yield* checkKimiProviderStatus( + decodeKimiSettings({ enabled: true, binaryPath: kimiPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Kimi CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("reports a transient warning when ACP model discovery is unavailable", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-acp-" }); + const kimiPath = path.join(dir, platform === "win32" ? "kimi.cmd" : "kimi"); + yield* fs.writeFileString( + kimiPath, + platform === "win32" + ? [ + "@echo off", + 'if "%~1"=="--version" (', + " echo kimi-cli 1.49.0", + " exit /b 0", + ")", + "echo not-json", + "exit /b 0", + ].join("\r\n") + : [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "kimi-cli 1.49.0\\n"', + " exit 0", + "fi", + 'printf "not-json\\n"', + "exit 0", + ].join("\n"), + ); + if (platform !== "win32") { + yield* fs.chmod(kimiPath, 0o755); + } + return yield* checkKimiProviderStatus( + decodeKimiSettings({ enabled: true, binaryPath: kimiPath }), + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "k3", + "kimi-for-coding", + "kimi-for-coding-highspeed", + ]); + expect(snapshot.message).toContain("failed temporarily"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/KimiProvider.ts b/apps/server/src/provider/Layers/KimiProvider.ts new file mode 100644 index 000000000000..5a2a50438f0e --- /dev/null +++ b/apps/server/src/provider/Layers/KimiProvider.ts @@ -0,0 +1,763 @@ +import { + type KimiSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + buildSelectOptionDescriptor, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + findKimiThinkingConfigOption, + flattenKimiSelectConfigOptions, + isKimiAuthRequiredError, + kimiModelStateFromSessionSetup, + kimiSessionHasModelConfigOption, + makeKimiAcpRuntime, + probeKimiAcpAuthentication, + resolveKimiAcpBaseModelId, + resolveKimiHomePath, +} from "../acp/KimiAcpSupport.ts"; + +const KIMI_PRESENTATION = { + displayName: "Kimi", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +export const KIMI_VERSION_PROBE_TIMEOUT_MS = 10_000; +export const KIMI_ACP_AUTH_PROBE_TIMEOUT_MS = 10_000; +export const KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 20_000; + +export type KimiProbeStage = "version" | "authentication" | "discovery"; + +export type KimiProbeClassification = + | { readonly _tag: "disabled" } + | { readonly _tag: "healthy"; readonly modelSource: "cache" | "discovery" } + | { readonly _tag: "command-missing" } + | { readonly _tag: "auth-required" } + | { readonly _tag: "non-zero-exit"; readonly exitCode: number } + | { readonly _tag: "acp-failure"; readonly stage: KimiProbeStage; readonly errorTag: string } + | { + readonly _tag: "transient-timeout"; + readonly stage: KimiProbeStage; + readonly timeoutMs: number; + } + | { + readonly _tag: "transient-process-failure"; + readonly stage: KimiProbeStage; + readonly errorTag: string; + }; + +export interface KimiModelDiscoveryCache { + readonly key: string; + readonly models: ReadonlyArray; +} + +export interface KimiProviderProbeResult { + readonly classification: KimiProbeClassification; + readonly snapshot: Snapshot; + readonly discoveryCache?: KimiModelDiscoveryCache; +} + +export function isTransientKimiProbeClassification( + classification: KimiProbeClassification, +): boolean { + return ( + classification._tag === "transient-timeout" || + classification._tag === "transient-process-failure" + ); +} + +export const KIMI_NOT_SIGNED_IN_MESSAGE = + "Kimi CLI is installed but not signed in. Use Sign in with Kimi in Settings or run `kimi login`."; + +// Static fallback matching current kimi-cli builds. Live ACP discovery +// replaces this list whenever the CLI is installed and signed in. +const KIMI_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "k3", + name: "Kimi K3", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "kimi-for-coding", + name: "Kimi K2.7 Coding", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "kimi-for-coding-highspeed", + name: "Kimi K2.7 Coding Highspeed", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialKimiProviderSnapshot( + kimiSettings: KimiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = kimiModelsFromSettings(kimiSettings.customModels); + + if (!kimiSettings.enabled) { + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Kimi CLI availability...", + }, + }); + }); +} + +function kimiModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = KIMI_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildKimiThinkingCapabilitiesFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ModelCapabilities { + const thinkingConfig = findKimiThinkingConfigOption(configOptions); + if (!thinkingConfig) { + return EMPTY_CAPABILITIES; + } + const currentValue = thinkingConfig.currentValue.trim(); + const seen = new Set(); + const options = flattenKimiSelectConfigOptions(thinkingConfig).flatMap((option) => { + const value = option.value.trim(); + if (!value || seen.has(value)) { + return []; + } + seen.add(value); + const description = option.description?.trim() || undefined; + return [ + { + value, + label: option.name.trim() || value, + ...(description ? { description } : {}), + ...(value === currentValue ? { isDefault: true } : {}), + }, + ]; + }); + const configId = thinkingConfig.id.trim(); + if (!configId || options.length === 0) { + return EMPTY_CAPABILITIES; + } + const description = thinkingConfig.description?.trim() || undefined; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: configId, + label: thinkingConfig.name.trim() || "Thinking", + options, + ...(description ? { description } : {}), + }), + ], + }); +} + +export function buildKimiDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, + capabilitiesByModelId: ReadonlyMap = new Map(), +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const currentBaseModelId = modelState.currentModelId + ? resolveKimiAcpBaseModelId(modelState.currentModelId) + : undefined; + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveKimiAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(slug === currentBaseModelId ? { isDefault: true } : {}), + capabilities: capabilitiesByModelId.get(model.modelId) ?? EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +export type KimiVersionProbeResult = + | { + readonly _tag: "success"; + readonly version: string | null; + readonly resolvedBinaryPath: string; + } + | { readonly _tag: "command-missing" } + | { readonly _tag: "non-zero-exit"; readonly exitCode: number; readonly version: string | null } + | { readonly _tag: "transient-timeout"; readonly timeoutMs: number } + | { readonly _tag: "transient-process-failure"; readonly errorTag: string }; + +export type KimiAcpProbeResult = + | { readonly _tag: "success"; readonly value: A } + | { readonly _tag: "auth-required" } + | { readonly _tag: "failure"; readonly errorTag: string } + | { readonly _tag: "transient-timeout"; readonly timeoutMs: number } + | { readonly _tag: "transient-process-failure"; readonly errorTag: string }; + +type KimiProbeEnvironment = ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto; + +export interface KimiProviderProbeOperations { + readonly probeVersion: ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly probeAuthentication: ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, + ) => Effect.Effect, never, KimiProbeEnvironment>; + readonly discoverModels: ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, + ) => Effect.Effect< + KimiAcpProbeResult>, + never, + KimiProbeEnvironment + >; +} + +const discoverKimiModelsViaAcp = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeKimiAcpRuntime({ + kimiSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + const modelState = kimiModelStateFromSessionSetup(started.sessionSetupResult); + if (!modelState || !kimiSessionHasModelConfigOption(started.sessionSetupResult)) { + return buildKimiDiscoveredModelsFromSessionModelState(modelState); + } + const capabilitiesByModelId = new Map(); + yield* Effect.forEach( + modelState.availableModels, + (model) => + acp.setModel(model.modelId).pipe( + Effect.andThen(acp.getConfigOptions), + Effect.tap((configOptions) => + Effect.sync(() => { + capabilitiesByModelId.set( + model.modelId, + buildKimiThinkingCapabilitiesFromConfigOptions(configOptions), + ); + }), + ), + ), + { discard: true }, + ).pipe(Effect.ensuring(acp.setModel(modelState.currentModelId).pipe(Effect.ignore))); + return buildKimiDiscoveredModelsFromSessionModelState(modelState, capabilitiesByModelId); + }).pipe(Effect.scoped); + +const runKimiVersionCommand = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = kimiSettings.binaryPath || "kimi"; + const homePath = resolveKimiHomePath(kimiSettings); + const env = homePath ? { ...environment, KIMI_CODE_HOME: homePath } : environment; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env, + }); + const result = yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env, + shell: spawnCommand.shell, + }), + ); + return { ...result, resolvedBinaryPath: spawnCommand.command }; + }); + +function errorTag(error: unknown): string { + if (typeof error !== "object" || error === null || !("_tag" in error)) { + return "Unknown"; + } + return typeof error._tag === "string" ? error._tag : "Unknown"; +} + +function isTransientKimiAcpError(error: unknown): boolean { + return [ + "AcpSpawnError", + "AcpProcessExitedError", + "AcpTransportError", + "AcpInputStreamEndedError", + ].includes(errorTag(error)); +} + +function classifyKimiAcpExit( + exit: Exit.Exit, unknown>, + timeoutMs: number, +): KimiAcpProbeResult { + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + if (Option.isSome(failure) && isKimiAuthRequiredError(failure.value)) { + return { _tag: "auth-required" }; + } + const classifiedErrorTag = causeErrorTag(exit.cause); + return Option.isSome(failure) && isTransientKimiAcpError(failure.value) + ? { _tag: "transient-process-failure", errorTag: classifiedErrorTag } + : { _tag: "failure", errorTag: classifiedErrorTag }; + } + if (Option.isNone(exit.value)) { + return { _tag: "transient-timeout", timeoutMs }; + } + return { _tag: "success", value: exit.value.value }; +} + +const probeKimiVersion = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, +): Effect.Effect => + runKimiVersionCommand(kimiSettings, environment).pipe( + Effect.timeoutOption(KIMI_VERSION_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result): KimiVersionProbeResult => { + if (Result.isFailure(result)) { + if (isCommandMissingCause(result.failure)) { + return { _tag: "command-missing" }; + } + return { _tag: "transient-process-failure", errorTag: errorTag(result.failure) }; + } + if (Option.isNone(result.success)) { + return { _tag: "transient-timeout", timeoutMs: KIMI_VERSION_PROBE_TIMEOUT_MS }; + } + const output = result.success.value; + const version = parseGenericCliVersion(`${output.stdout}\n${output.stderr}`); + if (output.code !== 0) { + return { _tag: "non-zero-exit", exitCode: output.code, version }; + } + return { _tag: "success", version, resolvedBinaryPath: output.resolvedBinaryPath }; + }), + ); + +const probeKimiAuthentication = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, +): Effect.Effect, never, KimiProbeEnvironment> => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* probeKimiAcpAuthentication({ + kimiSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + }).pipe( + Effect.scoped, + Effect.timeoutOption(KIMI_ACP_AUTH_PROBE_TIMEOUT_MS), + Effect.exit, + Effect.map((exit) => classifyKimiAcpExit(exit, KIMI_ACP_AUTH_PROBE_TIMEOUT_MS)), + ); + +const probeKimiModelDiscovery = ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv, +): Effect.Effect< + KimiAcpProbeResult>, + never, + KimiProbeEnvironment +> => + discoverKimiModelsViaAcp(kimiSettings, environment).pipe( + Effect.timeoutOption(KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + Effect.map((exit) => classifyKimiAcpExit(exit, KIMI_ACP_MODEL_DISCOVERY_TIMEOUT_MS)), + ); + +const LIVE_KIMI_PROBE_OPERATIONS: KimiProviderProbeOperations = { + probeVersion: probeKimiVersion, + probeAuthentication: probeKimiAuthentication, + discoverModels: probeKimiModelDiscovery, +}; + +export function buildKimiModelDiscoveryCacheKey(input: { + readonly version: string | null; + readonly resolvedBinaryPath: string; + readonly kimiSettings: KimiSettings; +}): string { + return JSON.stringify({ + version: input.version, + binaryPath: input.resolvedBinaryPath, + homePath: resolveKimiHomePath(input.kimiSettings) ?? null, + settings: { + binaryPath: input.kimiSettings.binaryPath, + homePath: input.kimiSettings.homePath, + customModels: input.kimiSettings.customModels, + }, + }); +} + +export interface KimiProviderStatusProbeOptions { + readonly discoveryCache?: KimiModelDiscoveryCache; + readonly operations?: KimiProviderProbeOperations; +} + +export const probeKimiProviderStatus = Effect.fn("probeKimiProviderStatus")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, + options: KimiProviderStatusProbeOptions = {}, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = kimiModelsFromSettings(kimiSettings.customModels); + const operations = options.operations ?? LIVE_KIMI_PROBE_OPERATIONS; + + if (!kimiSettings.enabled) { + return { + classification: { _tag: "disabled" }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi is disabled in T3 Code settings.", + }, + }), + }; + } + + const versionResult = yield* operations.probeVersion(kimiSettings, environment); + if (versionResult._tag === "command-missing") { + return { + classification: { _tag: "command-missing" }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: + "Kimi CLI (`kimi`) is not installed or not on PATH. Install it from kimi.com/code or with `npm install -g @moonshot-ai/kimi-code`.", + }, + }), + }; + } + if (versionResult._tag === "non-zero-exit") { + yield* Effect.logWarning("Kimi CLI version probe exited with a non-zero status.", { + exitCode: versionResult.exitCode, + }); + return { + classification: { _tag: "non-zero-exit", exitCode: versionResult.exitCode }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: versionResult.version, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but failed to run.", + }, + }), + }; + } + if (versionResult._tag === "transient-timeout") { + yield* Effect.logWarning("Kimi CLI version probe timed out.", { + timeoutMs: versionResult.timeoutMs, + }); + return { + classification: { + _tag: "transient-timeout", + stage: "version", + timeoutMs: versionResult.timeoutMs, + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi CLI health check timed out. T3 Code will retry automatically.", + }, + }), + }; + } + if (versionResult._tag === "transient-process-failure") { + yield* Effect.logWarning("Kimi CLI health check failed transiently.", { + errorTag: versionResult.errorTag, + }); + return { + classification: { + _tag: "transient-process-failure", + stage: "version", + errorTag: versionResult.errorTag, + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi CLI health check failed temporarily. T3 Code will retry automatically.", + }, + }), + }; + } + + const { version, resolvedBinaryPath } = versionResult; + const cacheKey = buildKimiModelDiscoveryCacheKey({ + version, + resolvedBinaryPath, + kimiSettings, + }); + const cachedDiscovery = + options.discoveryCache?.key === cacheKey && options.discoveryCache.models.length > 0 + ? options.discoveryCache + : undefined; + let acpResult: KimiAcpProbeResult>; + if (cachedDiscovery) { + const authenticationResult = yield* operations.probeAuthentication(kimiSettings, environment); + acpResult = + authenticationResult._tag === "success" + ? { _tag: "success", value: cachedDiscovery.models } + : authenticationResult; + } else { + acpResult = yield* operations.discoverModels(kimiSettings, environment); + } + const acpStage: KimiProbeStage = cachedDiscovery ? "authentication" : "discovery"; + + if (acpResult._tag === "auth-required") { + return { + classification: { _tag: "auth-required" }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unauthenticated" }, + message: KIMI_NOT_SIGNED_IN_MESSAGE, + }, + }), + }; + } + if (acpResult._tag === "failure") { + yield* Effect.logWarning("Kimi ACP probe failed.", { + stage: acpStage, + errorTag: acpResult.errorTag, + }); + return { + classification: { + _tag: "acp-failure", + stage: acpStage, + errorTag: acpResult.errorTag, + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Kimi CLI is installed but ACP startup failed. Check server logs for details.", + }, + }), + }; + } + if (acpResult._tag === "transient-timeout") { + yield* Effect.logWarning("Kimi ACP probe timed out.", { + stage: acpStage, + timeoutMs: acpResult.timeoutMs, + }); + return { + classification: { + _tag: "transient-timeout", + stage: acpStage, + timeoutMs: acpResult.timeoutMs, + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi ACP health check timed out. T3 Code will retry automatically.", + }, + }), + }; + } + if (acpResult._tag === "transient-process-failure") { + yield* Effect.logWarning("Kimi ACP probe failed transiently.", { + stage: acpStage, + errorTag: acpResult.errorTag, + }); + return { + classification: { + _tag: "transient-process-failure", + stage: acpStage, + errorTag: acpResult.errorTag, + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Kimi ACP health check failed temporarily. T3 Code will retry automatically.", + }, + }), + }; + } + + const discoveredModels = acpResult.value; + const models = + discoveredModels.length > 0 + ? kimiModelsFromSettings(kimiSettings.customModels, discoveredModels) + : fallbackModels; + const discoveryCache = + cachedDiscovery ?? + (discoveredModels.length > 0 ? { key: cacheKey, models: discoveredModels } : undefined); + + return { + classification: { + _tag: "healthy", + modelSource: cachedDiscovery ? "cache" : "discovery", + }, + snapshot: buildServerProvider({ + presentation: KIMI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }), + ...(discoveryCache ? { discoveryCache } : {}), + }; +}); + +export const checkKimiProviderStatus = Effect.fn("checkKimiProviderStatus")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + return (yield* probeKimiProviderStatus(kimiSettings, environment)).snapshot; +}); + +export const enrichKimiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Kimi version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Services/KimiAdapter.ts b/apps/server/src/provider/Services/KimiAdapter.ts new file mode 100644 index 000000000000..c8e15aba78d4 --- /dev/null +++ b/apps/server/src/provider/Services/KimiAdapter.ts @@ -0,0 +1,16 @@ +/** + * KimiAdapter — shape type for the Kimi provider adapter. + * + * The driver model ({@link ../Drivers/KimiDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module KimiAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * KimiAdapterShape — per-instance Kimi adapter contract. + */ +export interface KimiAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/KimiAcpSupport.test.ts b/apps/server/src/provider/acp/KimiAcpSupport.test.ts new file mode 100644 index 000000000000..7d87f261be93 --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.test.ts @@ -0,0 +1,688 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + advertisedKimiModelIdsFromSessionSetup, + applyKimiAcpModeSelection, + applyKimiAcpModelSelection, + applyKimiAcpThinkingSelection, + buildKimiAcpSpawnInput, + classifyKimiPermissionRequest, + currentKimiModeIdFromSessionSetup, + currentKimiModelIdFromSessionSetup, + extractKimiProposedPlanMarkdown, + findKimiModelConfigOption, + findKimiThinkingConfigOption, + isKimiAuthRequiredError, + kimiConfigOptionsFromSessionNotification, + kimiModelStateFromSessionSetup, + kimiPermissionRequestDetail, + kimiSessionHasModelConfigOption, + resolveKimiAcpBaseModelId, + resolveKimiAcpModeId, + resolveKimiAcpWireModelId, + resolveKimiThinkingSelection, + shouldKimiAdapterAutoApprove, +} from "./KimiAcpSupport.ts"; + +const configOptionSession = { + sessionId: "session-1", + configOptions: [ + { + id: "chosen-model", + name: "Model", + category: "model", + type: "select", + currentValue: "kimi-code/k3", + options: [ + { value: "kimi-code/k3", name: "K3" }, + { value: "kimi-code/kimi-for-coding", name: "K2.7 Coding" }, + { value: "moonshot-ai/kimi-k3", name: "kimi-k3" }, + ], + }, + { + id: "permission-mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "plan", name: "Plan" }, + { value: "auto", name: "Auto" }, + { value: "yolo", name: "YOLO" }, + ], + }, + ], +} satisfies EffectAcpSchema.NewSessionResponse; + +function thinkingConfigOptions( + values: ReadonlyArray, + currentValue: string, +): ReadonlyArray { + return [ + { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue, + options: values.map((value) => ({ value, name: value.toUpperCase() })), + }, + ]; +} + +describe("resolveKimiAcpBaseModelId", () => { + it("normalizes empty and custom Kimi model ids", () => { + expect(resolveKimiAcpBaseModelId(undefined)).toBe("k3"); + expect(resolveKimiAcpBaseModelId(" ")).toBe("k3"); + expect(resolveKimiAcpBaseModelId(" kimi-for-coding ")).toBe("kimi-for-coding"); + }); + + it("strips the ,thinking variant suffix to the base model id", () => { + expect(resolveKimiAcpBaseModelId("k3,thinking")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("kimi-for-coding , thinking")).toBe("kimi-for-coding"); + }); + + it("strips the kimi-code/ namespace prefix used by config.toml aliases", () => { + expect(resolveKimiAcpBaseModelId("kimi-code/k3")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("kimi-code/k3,thinking")).toBe("k3"); + expect(resolveKimiAcpBaseModelId("moonshot-ai/kimi-k3")).toBe("moonshot-ai/kimi-k3"); + }); +}); + +describe("kimiModelStateFromSessionSetup", () => { + it("discovers exact model wire ids from a category-based select option", () => { + const modelConfig = findKimiModelConfigOption(configOptionSession.configOptions); + const modelState = kimiModelStateFromSessionSetup(configOptionSession); + + expect(modelConfig?.id).toBe("chosen-model"); + expect(modelState).toEqual({ + currentModelId: "kimi-code/k3", + availableModels: [ + { modelId: "kimi-code/k3", name: "K3" }, + { modelId: "kimi-code/kimi-for-coding", name: "K2.7 Coding" }, + { modelId: "moonshot-ai/kimi-k3", name: "kimi-k3" }, + ], + }); + expect(advertisedKimiModelIdsFromSessionSetup(configOptionSession)).toEqual([ + "kimi-code/k3", + "kimi-code/kimi-for-coding", + "moonshot-ai/kimi-k3", + ]); + expect(currentKimiModelIdFromSessionSetup(configOptionSession)).toBe("kimi-code/k3"); + expect(kimiSessionHasModelConfigOption(configOptionSession)).toBe(true); + }); + + it("falls back to the legacy model state when no model config option is advertised", () => { + const legacySession = { + sessionId: "legacy-session", + models: { + currentModelId: "k3", + availableModels: [ + { modelId: "k3", name: "K3" }, + { modelId: "kimi-for-coding", name: "K2.7 Coding" }, + ], + }, + } satisfies EffectAcpSchema.NewSessionResponse; + + expect(kimiModelStateFromSessionSetup(legacySession)).toEqual(legacySession.models); + expect(advertisedKimiModelIdsFromSessionSetup(legacySession)).toEqual([ + "k3", + "kimi-for-coding", + ]); + expect(kimiSessionHasModelConfigOption(legacySession)).toBe(false); + }); +}); + +describe("Kimi ACP modes", () => { + it("maps T3 runtime and interaction modes to native Kimi modes", () => { + expect(currentKimiModeIdFromSessionSetup(configOptionSession)).toBe("default"); + expect(resolveKimiAcpModeId({ runtimeMode: "approval-required" })).toBe("default"); + expect(resolveKimiAcpModeId({ runtimeMode: "auto-accept-edits" })).toBe("auto"); + expect(resolveKimiAcpModeId({ runtimeMode: "auto" })).toBe("auto"); + expect(resolveKimiAcpModeId({ runtimeMode: "full-access" })).toBe("yolo"); + for (const runtimeMode of [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ] as const) { + expect(resolveKimiAcpModeId({ runtimeMode, interactionMode: "plan" })).toBe("plan"); + } + }); + + it.effect("skips unchanged modes and restores the runtime-derived mode after plan", () => + Effect.gen(function* () { + const modeCalls: Array = []; + const runtime = { + setMode: (modeId: string) => + Effect.sync(() => { + modeCalls.push(modeId); + return {}; + }), + }; + const unchanged = yield* applyKimiAcpModeSelection({ + runtime, + currentModeId: "plan", + requestedModeId: "plan", + mapError: (cause) => cause.message, + }); + const restored = yield* applyKimiAcpModeSelection({ + runtime, + currentModeId: unchanged, + requestedModeId: resolveKimiAcpModeId({ + runtimeMode: "full-access", + interactionMode: "default", + }), + mapError: (cause) => cause.message, + }); + expect(modeCalls).toEqual(["yolo"]); + expect(restored).toBe("yolo"); + }), + ); + + it("auto-approves tool gates for full-access in any native mode, never user decisions", () => { + // Full access answers Kimi's tool-gate requests itself, whatever the + // tracked native mode is (including a stale plan mode left over from an + // intercepted ExitPlanMode). + expect(shouldKimiAdapterAutoApprove({ runtimeMode: "full-access" })).toBe(true); + expect(shouldKimiAdapterAutoApprove({ runtimeMode: "full-access", requestKind: "tool" })).toBe( + true, + ); + // Plan decisions and user questions are user decisions, never auto-approved. + expect( + shouldKimiAdapterAutoApprove({ runtimeMode: "full-access", requestKind: "plan-decision" }), + ).toBe(false); + expect( + shouldKimiAdapterAutoApprove({ runtimeMode: "full-access", requestKind: "user-question" }), + ).toBe(false); + // Non-full-access runtime modes keep the supervised behavior exactly. + expect(shouldKimiAdapterAutoApprove({ runtimeMode: "approval-required" })).toBe(false); + expect(shouldKimiAdapterAutoApprove({ runtimeMode: "auto-accept-edits" })).toBe(false); + expect(shouldKimiAdapterAutoApprove({ runtimeMode: "auto" })).toBe(false); + }); +}); + +describe("Kimi thinking configuration", () => { + it("resolves supported values and falls back stale selections to the advertised current value", () => { + const k3 = thinkingConfigOptions(["low", "high", "max"], "high"); + const k27 = thinkingConfigOptions(["on", "high"], "high"); + + expect(findKimiThinkingConfigOption(k3)?.id).toBe("thinking"); + expect(resolveKimiThinkingSelection({ configOptions: k3, requestedValue: "max" })).toEqual({ + configId: "thinking", + currentValue: "high", + selectedValue: "max", + usedFallback: false, + }); + expect(resolveKimiThinkingSelection({ configOptions: k27, requestedValue: "low" })).toEqual({ + configId: "thinking", + currentValue: "high", + selectedValue: "high", + usedFallback: true, + }); + }); + + it.effect("skips fallback/current writes and applies supported thinking values", () => + Effect.gen(function* () { + let configOptions = thinkingConfigOptions(["on", "high"], "high"); + const calls: Array<[string, string | boolean]> = []; + const runtime = { + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push([configId, value]); + configOptions = thinkingConfigOptions(["on", "high"], String(value)); + return { configOptions }; + }), + getConfigOptions: Effect.sync(() => configOptions), + }; + + const fallback = yield* applyKimiAcpThinkingSelection({ + runtime, + configOptions, + requestedValue: "low", + mapError: (cause) => cause.message, + }); + expect(calls).toEqual([]); + expect(fallback.resolution?.selectedValue).toBe("high"); + expect(fallback.resolution?.usedFallback).toBe(true); + + const applied = yield* applyKimiAcpThinkingSelection({ + runtime, + configOptions: fallback.configOptions, + requestedValue: "on", + mapError: (cause) => cause.message, + }); + expect(calls).toEqual([["thinking", "on"]]); + expect(findKimiThinkingConfigOption(applied.configOptions)?.currentValue).toBe("on"); + }), + ); + + it("extracts config option updates from session notifications", () => { + const configOptions = thinkingConfigOptions(["low", "high", "max"], "max"); + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "config_option_update", + configOptions, + }, + } satisfies EffectAcpSchema.SessionNotification; + + expect(kimiConfigOptionsFromSessionNotification(notification)).toEqual(configOptions); + }); +}); + +describe("resolveKimiAcpWireModelId", () => { + it("namespaces managed ids when nothing was advertised (Kimi Code CLI)", () => { + expect(resolveKimiAcpWireModelId("k3")).toBe("kimi-code/k3"); + expect(resolveKimiAcpWireModelId("kimi-for-coding")).toBe("kimi-code/kimi-for-coding"); + expect(resolveKimiAcpWireModelId("moonshot-ai/kimi-k3")).toBe("moonshot-ai/kimi-k3"); + expect(resolveKimiAcpWireModelId("k3", [])).toBe("kimi-code/k3"); + }); + + it("prefers a matching advertised id (kimi-cli advertises bare ids)", () => { + expect(resolveKimiAcpWireModelId("k3", ["k3", "k3,thinking", "kimi-for-coding"])).toBe("k3"); + expect(resolveKimiAcpWireModelId("k3", ["k3,thinking", "k3"])).toBe("k3"); + expect(resolveKimiAcpWireModelId("k3", ["k3,thinking"])).toBe("k3,thinking"); + expect(resolveKimiAcpWireModelId("k3", ["kimi-code/k3", "kimi-code/kimi-for-coding"])).toBe( + "kimi-code/k3", + ); + }); + + it("passes an unadvertised custom id through untouched", () => { + expect(resolveKimiAcpWireModelId("my-custom-model", ["k3", "kimi-for-coding"])).toBe( + "my-custom-model", + ); + }); +}); + +describe("buildKimiAcpSpawnInput", () => { + it("spawns `kimi acp` with the configured binary", () => { + const spawn = buildKimiAcpSpawnInput( + { binaryPath: "/usr/local/bin/kimi", homePath: "" }, + "/tmp/project", + ); + + expect(spawn).toEqual({ + command: "/usr/local/bin/kimi", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("injects KIMI_CODE_HOME when a homePath is configured", () => { + const spawn = buildKimiAcpSpawnInput( + { binaryPath: "kimi", homePath: "/data/kimi-work" }, + "/tmp/project", + { PATH: "/bin" }, + ); + + expect(spawn).toEqual({ + command: "kimi", + args: ["acp"], + cwd: "/tmp/project", + env: { + PATH: "/bin", + KIMI_CODE_HOME: "/data/kimi-work", + }, + }); + }); +}); + +describe("isKimiAuthRequiredError", () => { + it("matches the RFC auth-required error code", () => { + expect(isKimiAuthRequiredError(EffectAcpErrors.AcpRequestError.authRequired())).toBe(true); + }); + + it("rejects non-authenticate protocol failures even when the method is authenticate", () => { + expect( + isKimiAuthRequiredError( + new EffectAcpErrors.AcpRequestError({ + code: -32601, + errorMessage: "authenticate is unsupported", + method: "authenticate", + }), + ), + ).toBe(false); + expect( + isKimiAuthRequiredError( + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "invalid auth method", + method: "authenticate", + }), + ), + ).toBe(false); + }); + + it("rejects unrelated errors", () => { + expect( + isKimiAuthRequiredError(EffectAcpErrors.AcpRequestError.invalidParams("bad params")), + ).toBe(false); + expect(isKimiAuthRequiredError(new Error("nope"))).toBe(false); + }); +}); + +describe("applyKimiAcpModelSelection", () => { + const makeRecordingRuntime = (stableFailure?: EffectAcpErrors.AcpError) => { + const stableModelCalls: Array = []; + const fallbackModelCalls: Array = []; + const runtime = { + setModel: (modelId: string) => + Effect.gen(function* () { + stableModelCalls.push(modelId); + if (stableFailure) return yield* stableFailure; + }), + setSessionModel: (modelId: string) => + Effect.sync(() => { + fallbackModelCalls.push(modelId); + return {}; + }), + }; + return { runtime, stableModelCalls, fallbackModelCalls }; + }; + + it.effect("uses setModel and the exact advertised wire id for model config sessions", () => + Effect.gen(function* () { + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "kimi-code/k3", + requestedModelId: "kimi-for-coding", + advertisedModelIds: ["kimi-code/k3", "kimi-code/kimi-for-coding"], + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual(["kimi-code/kimi-for-coding"]); + expect(fallbackModelCalls).toEqual([]); + expect(result).toBe("kimi-for-coding"); + }), + ); + + it.effect("switches K3 to K2.7 and back through stable model configuration", () => + Effect.gen(function* () { + const { runtime, stableModelCalls } = makeRecordingRuntime(); + const advertisedModelIds = ["kimi-code/k3", "kimi-code/kimi-for-coding"]; + const k27 = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "kimi-code/k3", + requestedModelId: "kimi-for-coding", + advertisedModelIds, + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + const k3 = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: k27, + requestedModelId: "k3", + advertisedModelIds, + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual(["kimi-code/kimi-for-coding", "kimi-code/k3"]); + expect(k3).toBe("k3"); + }), + ); + + it.effect("falls back to session/set_model when stable model configuration is unsupported", () => + Effect.gen(function* () { + const unsupported = EffectAcpErrors.AcpRequestError.methodNotFound( + "session/set_config_option", + ); + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(unsupported); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "kimi-code/k3", + requestedModelId: "kimi-for-coding", + advertisedModelIds: ["kimi-code/k3", "kimi-code/kimi-for-coding"], + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual(["kimi-code/kimi-for-coding"]); + expect(fallbackModelCalls).toEqual(["kimi-code/kimi-for-coding"]); + expect(result).toBe("kimi-for-coding"); + }), + ); + + it.effect("falls back to session/set_model for legacy model-state sessions", () => + Effect.gen(function* () { + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "kimi-for-coding", + advertisedModelIds: ["k3", "k3,thinking", "kimi-for-coding"], + hasModelConfigOption: false, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual([]); + expect(fallbackModelCalls).toEqual(["kimi-for-coding"]); + expect(result).toBe("kimi-for-coding"); + }), + ); + + it.effect("skips model RPCs when requested matches current", () => + Effect.gen(function* () { + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "k3", + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual([]); + expect(fallbackModelCalls).toEqual([]); + expect(result).toBe("k3"); + }), + ); + + it.effect("treats a thinking-variant current model as its base id", () => + Effect.gen(function* () { + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3,thinking", + requestedModelId: "k3", + hasModelConfigOption: false, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual([]); + expect(fallbackModelCalls).toEqual([]); + expect(result).toBe("k3,thinking"); + }), + ); + + it.effect("skips model RPCs when no model is requested", () => + Effect.gen(function* () { + const { runtime, stableModelCalls, fallbackModelCalls } = makeRecordingRuntime(); + const result = yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: undefined, + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }); + expect(stableModelCalls).toEqual([]); + expect(fallbackModelCalls).toEqual([]); + expect(result).toBe("k3"); + }), + ); + + it.effect("does not report a new current model when setModel fails", () => + Effect.gen(function* () { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("model is unavailable"); + const { runtime, fallbackModelCalls } = makeRecordingRuntime(failure); + const error = yield* Effect.flip( + applyKimiAcpModelSelection({ + runtime, + currentModelId: "k3", + requestedModelId: "kimi-for-coding", + hasModelConfigOption: true, + mapError: (cause) => cause.message, + }), + ); + expect(error).toBe(failure.message); + expect(fallbackModelCalls).toEqual([]); + }), + ); +}); + +describe("Kimi permission request classification", () => { + const bashRequest = { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-bash-1", + title: "Bash", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { type: "text", text: 'Requesting approval to Running: echo "hello"' }, + }, + ], + }, + options: [ + { optionId: "approve_once", name: "Approve once", kind: "allow_once" }, + { optionId: "approve_always", name: "Approve for this session", kind: "allow_always" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + const exitPlanModeRequest = { + sessionId: "session-1", + toolCall: { + toolCallId: "tool-exit-plan-1", + title: "ExitPlanMode", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Plan saved to: D:/plans/mock-plan.md\n\n# Plan: Mock\n\n## Steps\n- do the thing", + }, + }, + { + type: "content", + content: { + type: "text", + text: "Requesting approval to Presenting plan and exiting plan mode", + }, + }, + ], + }, + options: [ + { optionId: "plan_approve", name: "Approve", kind: "allow_once" }, + { optionId: "plan_revise", name: "Revise", kind: "reject_once" }, + { optionId: "plan_reject_and_exit", name: "Reject and Exit", kind: "reject_once" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + it("classifies tool gates, plan decisions, and user questions", () => { + expect(classifyKimiPermissionRequest(bashRequest)).toBe("tool"); + expect(classifyKimiPermissionRequest(exitPlanModeRequest)).toBe("plan-decision"); + expect( + classifyKimiPermissionRequest({ + ...bashRequest, + toolCall: { toolCallId: "tool-q-1", title: "AskUserQuestion" }, + }), + ).toBe("user-question"); + // The plan_approve option id is the fallback signal when the title changes. + expect( + classifyKimiPermissionRequest({ + ...exitPlanModeRequest, + toolCall: { toolCallId: "tool-plan-2", title: "Present plan" }, + }), + ).toBe("plan-decision"); + }); + + it("extracts the plan markdown without the saved-path header", () => { + expect(extractKimiProposedPlanMarkdown(exitPlanModeRequest)).toBe( + "# Plan: Mock\n\n## Steps\n- do the thing", + ); + }); + + it("falls back to bare plan text and skips the approval-scaffolding entry", () => { + expect( + extractKimiProposedPlanMarkdown({ + ...exitPlanModeRequest, + toolCall: { + toolCallId: "tool-plan-3", + title: "ExitPlanMode", + content: [ + { + type: "content", + content: { + type: "text", + text: "Requesting approval to Presenting plan and exiting plan mode", + }, + }, + { + type: "content", + content: { type: "text", text: "# Plan: bare markdown" }, + }, + ], + }, + }), + ).toBe("# Plan: bare markdown"); + expect( + extractKimiProposedPlanMarkdown({ + ...exitPlanModeRequest, + toolCall: { toolCallId: "tool-plan-4", title: "ExitPlanMode" }, + }), + ).toBeUndefined(); + }); + + it("surfaces the real command text as the approval detail", () => { + expect(kimiPermissionRequestDetail(bashRequest)).toBe('Running: echo "hello"'); + expect( + kimiPermissionRequestDetail({ + ...bashRequest, + toolCall: { + toolCallId: "tool-q-2", + title: "AskUserQuestion", + content: [ + { type: "content", content: { type: "text", text: "What should the site be about?" } }, + ], + }, + }), + ).toBe("What should the site be about?"); + expect( + kimiPermissionRequestDetail({ + ...bashRequest, + toolCall: { toolCallId: "tool-bare-1", title: "Bash" }, + }), + ).toBeUndefined(); + }); + + it("surfaces the plan markdown as the plan-decision approval detail", () => { + expect(kimiPermissionRequestDetail(exitPlanModeRequest)).toBe( + "# Plan: Mock\n\n## Steps\n- do the thing", + ); + // A plan decision without usable plan text falls back to the stripped + // scaffolding text rather than the raw "Plan saved to:" line. + expect( + kimiPermissionRequestDetail({ + ...exitPlanModeRequest, + toolCall: { + toolCallId: "tool-plan-5", + title: "ExitPlanMode", + content: [ + { + type: "content", + content: { + type: "text", + text: "Requesting approval to Presenting plan and exiting plan mode", + }, + }, + ], + }, + }), + ).toBe("Presenting plan and exiting plan mode"); + }); +}); diff --git a/apps/server/src/provider/acp/KimiAcpSupport.ts b/apps/server/src/provider/acp/KimiAcpSupport.ts new file mode 100644 index 000000000000..2f74e467e58b --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpSupport.ts @@ -0,0 +1,674 @@ +import { + type KimiSettings, + type ProviderInteractionMode, + ProviderDriverKind, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpClient from "effect-acp/client"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { expandHomePath } from "../../pathExpansion.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const KIMI_CODE_HOME_ENV = "KIMI_CODE_HOME"; +// kimi-cli advertises a single terminal-auth method: `login`. The ACP +// `authenticate` call only validates the stored OAuth token; the actual +// device flow happens through `kimi login` (or T3's in-app sign-in, which +// writes the same credentials file). +const KIMI_AUTH_METHOD_LOGIN = "login"; +const KIMI_DRIVER_KIND = ProviderDriverKind.make("kimi"); + +type KimiAcpRuntimeKimiSettings = Pick; + +interface KimiAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + /** + * Advertise the ACP client terminal capability. Only adapter chat sessions + * set this: they register the terminal handlers. Text generation and the + * discovery/auth probes must stay `false` because they register none, and + * Kimi routes every shell command through the client once advertised. + */ + readonly terminal?: boolean; +} + +export interface KimiTurnActivity { + readonly markActive: (threadId: ThreadId) => Effect.Effect; + readonly markIdle: (threadId: ThreadId) => Effect.Effect; + readonly activeCount: Effect.Effect; + readonly awaitIdle: Effect.Effect; + readonly beginProbeIfIdle: Effect.Effect; + readonly endProbe: Effect.Effect; +} + +interface KimiTurnActivityState { + readonly activeThreadIds: ReadonlySet; + readonly idle: Deferred.Deferred; + readonly probesInFlight: number; +} + +export const makeKimiTurnActivity: Effect.Effect = Effect.gen(function* () { + const initialIdle = yield* Deferred.make(); + yield* Deferred.succeed(initialIdle, undefined); + const state = yield* SynchronizedRef.make({ + activeThreadIds: new Set(), + idle: initialIdle, + probesInFlight: 0, + }); + + const markActive = (threadId: ThreadId): Effect.Effect => + SynchronizedRef.modifyEffect( + state, + (current): Effect.Effect => { + if (current.activeThreadIds.has(threadId)) { + return Effect.succeed([undefined, current] as const); + } + return Effect.gen(function* () { + const activeThreadIds = new Set(current.activeThreadIds); + activeThreadIds.add(threadId); + const idle = + current.activeThreadIds.size === 0 ? yield* Deferred.make() : current.idle; + return [undefined, { ...current, activeThreadIds, idle }] as const; + }); + }, + ); + + const markIdle = (threadId: ThreadId): Effect.Effect => + SynchronizedRef.modify( + state, + (current): readonly [Deferred.Deferred | null, KimiTurnActivityState] => { + if (!current.activeThreadIds.has(threadId)) { + return [null, current] as const; + } + const activeThreadIds = new Set(current.activeThreadIds); + activeThreadIds.delete(threadId); + return [ + activeThreadIds.size === 0 ? current.idle : null, + { ...current, activeThreadIds }, + ] as const; + }, + ).pipe( + Effect.flatMap((idle) => + idle ? Deferred.succeed(idle, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ); + + const awaitIdle: Effect.Effect = Effect.gen(function* () { + while (true) { + const current = yield* SynchronizedRef.get(state); + if (current.activeThreadIds.size === 0) { + return; + } + yield* Deferred.await(current.idle); + } + }); + + const beginProbeIfIdle = SynchronizedRef.modify(state, (current) => + current.activeThreadIds.size > 0 + ? [false, current] + : [true, { ...current, probesInFlight: current.probesInFlight + 1 }], + ); + const endProbe = SynchronizedRef.update(state, (current) => ({ + ...current, + probesInFlight: Math.max(0, current.probesInFlight - 1), + })); + + return { + markActive, + markIdle, + activeCount: SynchronizedRef.get(state).pipe( + Effect.map((current) => current.activeThreadIds.size), + ), + awaitIdle, + beginProbeIfIdle, + endProbe, + } satisfies KimiTurnActivity; +}); + +export function resolveKimiHomePath( + kimiSettings: Pick | null | undefined, +): string | undefined { + const homePath = kimiSettings?.homePath?.trim(); + return homePath ? expandHomePath(homePath) : undefined; +} + +export function buildKimiAcpSpawnInput( + kimiSettings: KimiAcpRuntimeKimiSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const homePath = resolveKimiHomePath(kimiSettings); + return { + command: kimiSettings?.binaryPath || "kimi", + args: ["acp"], + cwd, + ...(environment || homePath + ? { + env: { + ...environment, + ...(homePath ? { [KIMI_CODE_HOME_ENV]: homePath } : {}), + }, + } + : {}), + }; +} + +export const probeKimiAcpAuthentication = Effect.fn("probeKimiAcpAuthentication")(function* ( + input: KimiAcpRuntimeInput, +) { + const spawnInput = buildKimiAcpSpawnInput(input.kimiSettings, input.cwd, input.environment); + const spawnCommand = yield* resolveSpawnCommand( + spawnInput.command, + spawnInput.args, + spawnInput.env ? { env: spawnInput.env, extendEnv: true } : {}, + ); + const child = yield* input.childProcessSpawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(spawnInput.cwd ? { cwd: spawnInput.cwd } : {}), + ...(spawnInput.env ? { env: spawnInput.env, extendEnv: true } : {}), + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpSpawnError({ + command: spawnInput.command, + cause, + }), + ), + ); + const acpContext = yield* Layer.build(EffectAcpClient.layerChildProcess(child)); + const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); + yield* acp.agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: input.clientInfo, + }); + yield* acp.agent.authenticate({ methodId: KIMI_AUTH_METHOD_LOGIN }); +}); + +export const makeKimiAcpRuntime = ( + input: KimiAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildKimiAcpSpawnInput(input.kimiSettings, input.cwd, input.environment), + authMethodId: KIMI_AUTH_METHOD_LOGIN, + ...(input.terminal ? { clientCapabilities: { terminal: true } } : {}), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +// kimi-code aliases its managed models as `kimi-code/` in config.toml, +// and its model-selection RPCs expect those alias ids. +const KIMI_CODE_MODEL_NAMESPACE = "kimi-code/"; + +type KimiSessionSetupResponse = + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + +type KimiSelectConfigOption = Extract< + EffectAcpSchema.SessionConfigOption, + { readonly type: "select" } +>; + +export function findKimiModelConfigOption( + configOptions: ReadonlyArray | null | undefined, +): KimiSelectConfigOption | undefined { + if (!configOptions) { + return undefined; + } + const selectOptions = configOptions.filter( + (option): option is KimiSelectConfigOption => option.type === "select", + ); + return ( + selectOptions.find((option) => option.category === "model") ?? + selectOptions.find((option) => option.id.trim() === "model") + ); +} + +export function findKimiThinkingConfigOption( + configOptions: ReadonlyArray | null | undefined, +): KimiSelectConfigOption | undefined { + if (!configOptions) { + return undefined; + } + const selectOptions = configOptions.filter( + (option): option is KimiSelectConfigOption => option.type === "select", + ); + return ( + selectOptions.find((option) => option.category === "thought_level") ?? + selectOptions.find((option) => option.id.trim() === "thinking") + ); +} + +export function flattenKimiSelectConfigOptions( + configOption: EffectAcpSchema.SessionConfigOption | null | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { + return []; + } + return configOption.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); +} + +export function kimiModelStateFromSessionSetup( + sessionSetupResult: KimiSessionSetupResponse, +): EffectAcpSchema.SessionModelState | undefined { + const modelConfig = findKimiModelConfigOption(sessionSetupResult.configOptions); + if (modelConfig) { + const currentModelId = modelConfig.currentValue.trim(); + const seen = new Set(); + const availableModels = flattenKimiSelectConfigOptions(modelConfig).flatMap((option) => { + const modelId = option.value.trim(); + if (!modelId || seen.has(modelId)) { + return []; + } + seen.add(modelId); + const name = option.name.trim() || modelId; + const description = option.description?.trim() || undefined; + return [ + { + modelId, + name, + ...(description ? { description } : {}), + } satisfies EffectAcpSchema.ModelInfo, + ]; + }); + if (currentModelId && availableModels.length > 0) { + return { currentModelId, availableModels }; + } + } + return sessionSetupResult.models ?? undefined; +} + +export function kimiSessionHasModelConfigOption( + sessionSetupResult: KimiSessionSetupResponse, +): boolean { + return findKimiModelConfigOption(sessionSetupResult.configOptions) !== undefined; +} + +export type KimiAcpModeId = "default" | "plan" | "auto" | "yolo"; + +export function resolveKimiAcpModeId(input: { + readonly runtimeMode: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode | undefined; +}): KimiAcpModeId { + if (input.interactionMode === "plan") { + return "plan"; + } + switch (input.runtimeMode) { + case "approval-required": + return "default"; + case "auto-accept-edits": + case "auto": + return "auto"; + case "full-access": + return "yolo"; + } +} + +export function currentKimiModeIdFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): string | undefined { + const options = configOptions ?? []; + const modeConfig = + options.find( + (option): option is KimiSelectConfigOption => + option.type === "select" && option.category === "mode", + ) ?? + options.find( + (option): option is KimiSelectConfigOption => + option.type === "select" && option.id.trim() === "mode", + ); + return modeConfig?.currentValue.trim() || undefined; +} + +export function currentKimiModeIdFromSessionSetup( + sessionSetupResult: KimiSessionSetupResponse, +): string | undefined { + return ( + (currentKimiModeIdFromConfigOptions(sessionSetupResult.configOptions) ?? + sessionSetupResult.modes?.currentModeId.trim()) || + undefined + ); +} + +export function applyKimiAcpModeSelection(input: { + readonly runtime: Pick; + readonly currentModeId: string | undefined; + readonly requestedModeId: KimiAcpModeId; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + if (input.currentModeId === input.requestedModeId) { + return Effect.succeed(input.requestedModeId); + } + return input.runtime + .setMode(input.requestedModeId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModeId)); +} + +/** + * Kimi routes three different things through `session/request_permission`: + * tool gates (Bash, edits), the plan decision (`ExitPlanMode`), and user + * questions (`AskUserQuestion`). Only tool gates are approvable on the user's + * behalf; the other two are user decisions and must always reach the UI. + */ +export type KimiPermissionRequestKind = "tool" | "plan-decision" | "user-question"; + +export function classifyKimiPermissionRequest( + params: EffectAcpSchema.RequestPermissionRequest, +): KimiPermissionRequestKind { + const title = params.toolCall.title?.trim(); + if ( + title === "ExitPlanMode" || + params.options.some((option) => option.optionId.trim() === "plan_approve") + ) { + return "plan-decision"; + } + if (title === "AskUserQuestion") { + return "user-question"; + } + return "tool"; +} + +// kimi-cli composes human-readable text entries on the permission request's +// tool call instead of filling rawInput. Observed on 0.37.2: +// Bash: "Requesting approval to Running: " +// ExitPlanMode: "Plan saved to: \n\n" plus a trailing +// "Requesting approval to Presenting plan and exiting plan mode" +// AskUserQuestion: the bare question text. +const KIMI_APPROVAL_TEXT_PREFIX = "Requesting approval to "; +const KIMI_PLAN_SAVED_PREFIX = "Plan saved to: "; + +function kimiPermissionContentTexts( + params: EffectAcpSchema.RequestPermissionRequest, +): Array { + const texts: Array = []; + for (const entry of params.toolCall.content ?? []) { + if (entry.type !== "content" || entry.content.type !== "text") { + continue; + } + const text = entry.content.text.trim(); + if (text.length > 0) { + texts.push(text); + } + } + return texts; +} + +/** + * The plan markdown from an ExitPlanMode permission request, without the + * "Plan saved to: " header line. Kimi does not stream ACP plan entries + * in plan mode, so this payload is the only plan-markdown source and becomes + * the approval card's detail text. + */ +export function extractKimiProposedPlanMarkdown( + params: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const texts = kimiPermissionContentTexts(params); + for (const text of texts) { + if (!text.startsWith(KIMI_PLAN_SAVED_PREFIX)) { + continue; + } + const separatorIndex = text.indexOf("\n\n"); + const markdown = (separatorIndex === -1 ? "" : text.slice(separatorIndex + 2)).trim(); + if (markdown.length > 0) { + return markdown; + } + } + return texts.find((text) => !text.startsWith(KIMI_APPROVAL_TEXT_PREFIX)); +} + +/** + * What the approval card should say for a Kimi permission request: the actual + * command, plan, or question text, not just the tool title. Kimi prefixes + * tool-gate text with "Requesting approval to "; that scaffolding is stripped. + * A plan decision's detail is the plan markdown itself, without the + * "Plan saved to: " header line. + */ +export function kimiPermissionRequestDetail( + params: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + if (classifyKimiPermissionRequest(params) === "plan-decision") { + const planMarkdown = extractKimiProposedPlanMarkdown(params); + if (planMarkdown !== undefined) { + return planMarkdown; + } + } + const text = kimiPermissionContentTexts(params)[0]; + if (text === undefined) { + return undefined; + } + const stripped = text.startsWith(KIMI_APPROVAL_TEXT_PREFIX) + ? text.slice(KIMI_APPROVAL_TEXT_PREFIX.length).trim() + : text; + return stripped.length > 0 ? stripped : undefined; +} + +export function shouldKimiAdapterAutoApprove(input: { + readonly runtimeMode: RuntimeMode; + readonly requestKind?: KimiPermissionRequestKind | undefined; +}): boolean { + // User decisions (plan approval, clarifying questions) are never answered + // on the user's behalf, regardless of runtime mode. + if (input.requestKind !== undefined && input.requestKind !== "tool") { + return false; + } + // Full access means T3 answers Kimi's tool-gate permission requests in + // every native mode. Kimi's own per-mode behavior is unchanged; a stale + // tracked mode (e.g. plan left behind by a natively approved plan exit, + // before the next turn re-syncs it) must not re-gate every command. + return input.runtimeMode === "full-access"; +} + +/** + * Kimi model ids may carry a `kimi-code/` namespace prefix and a `,thinking` + * variant suffix (e.g. `kimi-code/k3,thinking`). Selection and display always + * use the base id; the thinking level is a separate session config option in + * current kimi-cli builds. + */ +export function resolveKimiAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const withoutVariant = trimmed?.split(",", 1)[0]?.trim(); + const withoutNamespace = withoutVariant?.startsWith(KIMI_CODE_MODEL_NAMESPACE) + ? withoutVariant.slice(KIMI_CODE_MODEL_NAMESPACE.length) + : withoutVariant; + const base = withoutNamespace && withoutNamespace.length > 0 ? withoutNamespace : "k3"; + return normalizeModelSlug(base, KIMI_DRIVER_KIND) ?? "k3"; +} + +/** + * The ACP wire id for a base model id, resolved against the ids the agent + * advertised at session setup when it did. + * + * kimi-cli (PyPI) advertises `availableModels` with bare ids (`k3`, + * `k3,thinking`) and expects those on `session/set_model`. Kimi Code CLI + * (~0.37) advertises namespaced config.toml alias ids through its model config + * option. Matching an advertised id wins; with nothing advertised, bare ids + * get the `kimi-code/` namespace; ids that already carry a namespace (custom + * models such as `moonshot-ai/kimi-k3`) always pass through as-is. + */ +export function resolveKimiAcpWireModelId( + baseModelId: string, + advertisedModelIds?: ReadonlyArray | undefined, +): string { + if (advertisedModelIds && advertisedModelIds.length > 0) { + const matches = advertisedModelIds.filter( + (advertised) => resolveKimiAcpBaseModelId(advertised) === baseModelId, + ); + // Prefer the plain id over its `,thinking` variant when both are advertised. + const match = matches.find((advertised) => !advertised.includes(",")) ?? matches[0]; + if (match !== undefined) { + return match; + } + // A custom model the agent did not advertise: trust the configured id. + return baseModelId; + } + return baseModelId.includes("/") ? baseModelId : `${KIMI_CODE_MODEL_NAMESPACE}${baseModelId}`; +} + +export function advertisedKimiModelIdsFromSessionSetup( + sessionSetupResult: KimiSessionSetupResponse, +): ReadonlyArray | undefined { + const models = kimiModelStateFromSessionSetup(sessionSetupResult)?.availableModels; + return models && models.length > 0 ? models.map((model) => model.modelId) : undefined; +} + +export function currentKimiModelIdFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): string | undefined { + return findKimiModelConfigOption(configOptions)?.currentValue.trim() || undefined; +} + +export function currentKimiModelIdFromSessionSetup( + sessionSetupResult: KimiSessionSetupResponse, +): string | undefined { + return kimiModelStateFromSessionSetup(sessionSetupResult)?.currentModelId?.trim() || undefined; +} + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); + +export function applyKimiAcpModelSelection(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "setModel" | "setSessionModel" + >; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly advertisedModelIds?: ReadonlyArray | undefined; + readonly hasModelConfigOption: boolean; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const currentBaseModelId = input.currentModelId + ? resolveKimiAcpBaseModelId(input.currentModelId) + : undefined; + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== currentBaseModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + const wireModelId = resolveKimiAcpWireModelId(input.requestedModelId, input.advertisedModelIds); + const applyFallbackModel = input.runtime.setSessionModel(wireModelId).pipe(Effect.asVoid); + const applyModel = input.hasModelConfigOption + ? input.runtime.setModel(wireModelId).pipe( + Effect.catchIf( + (cause) => isAcpRequestError(cause) && cause.code === -32601, + () => applyFallbackModel, + ), + ) + : applyFallbackModel; + return applyModel.pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} + +export interface KimiThinkingSelectionResolution { + readonly configId: string; + readonly currentValue: string; + readonly selectedValue: string; + readonly usedFallback: boolean; +} + +export function resolveKimiThinkingSelection(input: { + readonly configOptions: ReadonlyArray; + readonly requestedValue: string | undefined; +}): KimiThinkingSelectionResolution | undefined { + const thinkingConfig = findKimiThinkingConfigOption(input.configOptions); + if (!thinkingConfig) { + return undefined; + } + const values = flattenKimiSelectConfigOptions(thinkingConfig) + .map((option) => option.value.trim()) + .filter((value) => value.length > 0); + if (values.length === 0) { + return undefined; + } + const configId = thinkingConfig.id.trim(); + const currentValue = thinkingConfig.currentValue.trim(); + const fallbackValue = values.includes(currentValue) ? currentValue : values[0]; + if (!configId || !fallbackValue) { + return undefined; + } + const requestedValue = input.requestedValue?.trim(); + const selectedValue = + requestedValue && values.includes(requestedValue) ? requestedValue : fallbackValue; + return { + configId, + currentValue, + selectedValue, + usedFallback: requestedValue !== undefined && requestedValue !== selectedValue, + }; +} + +export function applyKimiAcpThinkingSelection(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >; + readonly configOptions: ReadonlyArray; + readonly requestedValue: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect< + { + readonly configOptions: ReadonlyArray; + readonly resolution: KimiThinkingSelectionResolution | undefined; + }, + E +> { + const resolution = resolveKimiThinkingSelection(input); + if (!resolution || resolution.selectedValue === resolution.currentValue) { + return Effect.succeed({ configOptions: input.configOptions, resolution }); + } + return input.runtime.setConfigOption(resolution.configId, resolution.selectedValue).pipe( + Effect.mapError(input.mapError), + Effect.andThen(input.runtime.getConfigOptions), + Effect.map((configOptions) => ({ configOptions, resolution })), + ); +} + +export function kimiConfigOptionsFromSessionNotification( + notification: EffectAcpSchema.SessionNotification, +): ReadonlyArray | undefined { + return notification.update.sessionUpdate === "config_option_update" + ? notification.update.configOptions + : undefined; +} + +/** True when Kimi reports its real missing-credential ACP error. */ +export function isKimiAuthRequiredError(error: unknown): boolean { + return isAcpRequestError(error) && error.code === -32000; +} diff --git a/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts b/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts new file mode 100644 index 000000000000..ad5821f085fc --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpTerminalSupport.test.ts @@ -0,0 +1,347 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + makeKimiAcpTerminalManager, + type KimiAcpTerminalManager, +} from "./KimiAcpTerminalSupport.ts"; + +const SESSION_ID = "kimi-terminal-test-session"; + +const withTerminalManager = ( + body: (manager: KimiAcpTerminalManager) => Effect.Effect, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const manager = yield* makeKimiAcpTerminalManager({ childProcessSpawner }); + return yield* body(manager).pipe(Effect.ensuring(manager.shutdown)); + }).pipe(Effect.provide(NodeServices.layer)); + +const nodeScript = (script: string): { command: string; args: ReadonlyArray } => ({ + command: process.execPath, + args: ["-e", script], +}); + +describe("KimiAcpTerminalSupport", () => { + it.effect("runs a command through the full create/wait/output/release lifecycle", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript( + "process.stdout.write('hello-stdout');process.stderr.write('hello-stderr');process.exit(3)", + ); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + }); + assert.isString(created.terminalId); + + const exit = yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + // Top-level shape: exitCode/signal directly on the response, never + // nested under exitStatus (Kimi reads a nested shape as exit -1). + assert.strictEqual(exit.exitCode, 3); + assert.isUndefined(exit.signal); + assert.notProperty(exit, "exitStatus"); + + const output = yield* manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + assert.include(output.output, "hello-stdout"); + assert.include(output.output, "hello-stderr"); + assert.isFalse(output.truncated); + assert.strictEqual(output.exitStatus?.exitCode, 3); + + yield* manager.handleTerminalRelease({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + const releasedOutput = yield* Effect.flip( + manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }), + ); + assert.strictEqual(releasedOutput._tag, "AcpRequestError"); + }), + ), + ); + + it.effect("applies request env vars and cwd to the spawned process", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript( + "process.stdout.write(process.env.KIMI_TERMINAL_TEST + '|' + process.cwd())", + ); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + cwd: process.cwd(), + env: [{ name: "KIMI_TERMINAL_TEST", value: "env-visible" }], + }); + yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + const output = yield* manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + assert.include(output.output, "env-visible|"); + assert.include(output.output, process.cwd()); + }), + ), + ); + + it.effect("truncates output from the beginning when outputByteLimit is exceeded", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript("process.stdout.write('0123456789ABCDEF')"); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + outputByteLimit: 8, + }); + yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + const output = yield* manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + assert.isTrue(output.truncated); + assert.strictEqual(output.output, "89ABCDEF"); + }), + ), + ); + + it.effect("kill terminates a long-running command and is idempotent", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + }); + yield* manager.handleTerminalKill({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + const exit = yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + // Killed processes report a signal (POSIX) or a non-zero exit code + // (Windows); either way the command must not report success. + assert.isTrue(exit.signal !== undefined || (exit.exitCode ?? 0) !== 0); + // Second kill after exit is a no-op success. + yield* manager.handleTerminalKill({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + }), + ), + ); + + it.effect( + "killAll settles pending waits with SIGTERM, keeps terminals readable, and is idempotent", + () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript("setInterval(() => {}, 1000)"); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + }); + // Park a wait_for_exit on the never-exiting command, mirroring an + // agent blocked mid-turn. + const waitFiber = yield* manager + .handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }) + .pipe(Effect.forkChild); + + yield* manager.killAll; + const exit = yield* Fiber.join(waitFiber); + assert.isUndefined(exit.exitCode); + assert.strictEqual(exit.signal, "SIGTERM"); + assert.notProperty(exit, "exitStatus"); + + // Kill, not release: output and exit status stay readable afterward. + const output = yield* manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + assert.strictEqual(output.exitStatus?.signal, "SIGTERM"); + + // Already-killed terminals are no-ops. + yield* manager.killAll; + const exitAfter = yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + assert.strictEqual(exitAfter.signal, "SIGTERM"); + + // The agent may still release a killed terminal. + yield* manager.handleTerminalRelease({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + }), + ), + ); + + it.effect("killAll kills a terminal whose create is still awaiting spawn", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnStarted = yield* Deferred.make(); + const releaseSpawn = yield* Deferred.make(); + const manager = yield* makeKimiAcpTerminalManager({ + childProcessSpawner: { + ...childProcessSpawner, + spawn: (command) => + Deferred.succeed(spawnStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSpawn)), + Effect.andThen(childProcessSpawner.spawn(command)), + ), + }, + }); + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const createFiber = yield* manager + .handleCreateTerminal({ sessionId: SESSION_ID, command, args }) + .pipe(Effect.forkChild); + + yield* Deferred.await(spawnStarted); + yield* manager.killAll; + yield* Deferred.succeed(releaseSpawn, undefined); + const created = yield* Fiber.join(createFiber); + const exit = yield* manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }); + + assert.isUndefined(exit.exitCode); + assert.strictEqual(exit.signal, "SIGTERM"); + yield* manager.shutdown; + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("shutdown disposes a terminal whose create is still awaiting spawn", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnStarted = yield* Deferred.make(); + const releaseSpawn = yield* Deferred.make(); + const manager = yield* makeKimiAcpTerminalManager({ + childProcessSpawner: { + ...childProcessSpawner, + spawn: (command) => + Deferred.succeed(spawnStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSpawn)), + Effect.andThen(childProcessSpawner.spawn(command)), + ), + }, + }); + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const createFiber = yield* manager + .handleCreateTerminal({ sessionId: SESSION_ID, command, args }) + .pipe(Effect.forkChild); + + yield* Deferred.await(spawnStarted); + yield* manager.shutdown; + yield* Deferred.succeed(releaseSpawn, undefined); + const error = yield* Fiber.join(createFiber).pipe(Effect.flip); + + assert.strictEqual(error._tag, "AcpRequestError"); + assert.include(error._tag === "AcpRequestError" ? error.errorMessage : "", "session stopped"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("interrupting a spawned creation closes its scope and clears pending ownership", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnCompleted = yield* Deferred.make(); + const holdRegistration = yield* Deferred.make(); + const finalizerRan = yield* Deferred.make(); + const manager = yield* makeKimiAcpTerminalManager({ + childProcessSpawner: { + ...childProcessSpawner, + spawn: (command) => + Effect.gen(function* () { + const handle = yield* childProcessSpawner.spawn(command); + yield* Effect.addFinalizer(() => + Deferred.succeed(finalizerRan, undefined).pipe(Effect.asVoid), + ); + yield* Deferred.succeed(spawnCompleted, undefined); + yield* Deferred.await(holdRegistration); + return handle; + }), + }, + }); + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const createFiber = yield* manager + .handleCreateTerminal({ sessionId: SESSION_ID, command, args }) + .pipe(Effect.forkChild); + + yield* Deferred.await(spawnCompleted); + yield* Fiber.interrupt(createFiber); + yield* Deferred.await(finalizerRan); + assert.isTrue(yield* Deferred.isDone(finalizerRan)); + yield* manager.killAll; + yield* manager.shutdown; + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("shutdown kills open terminals and forgets them", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const { command, args } = nodeScript("setTimeout(() => {}, 600000)"); + const created = yield* manager.handleCreateTerminal({ + sessionId: SESSION_ID, + command, + args, + }); + // Shutdown closes each terminal scope; the spawner finalizer kills + // the process and waits for it to exit before returning. + yield* manager.shutdown; + const error = yield* Effect.flip( + manager.handleTerminalWaitForExit({ + sessionId: SESSION_ID, + terminalId: created.terminalId, + }), + ); + assert.strictEqual(error._tag, "AcpRequestError"); + }), + ), + ); + + it.effect("reports unknown terminal ids as AcpRequestError", () => + withTerminalManager((manager) => + Effect.gen(function* () { + const error = yield* Effect.flip( + manager.handleTerminalOutput({ + sessionId: SESSION_ID, + terminalId: "term-does-not-exist", + }), + ); + assert.strictEqual(error._tag, "AcpRequestError"); + assert.include( + error._tag === "AcpRequestError" ? error.errorMessage : "", + "term-does-not-exist", + ); + }), + ), + ); +}); diff --git a/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts b/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts new file mode 100644 index 000000000000..def4ed63fbce --- /dev/null +++ b/apps/server/src/provider/acp/KimiAcpTerminalSupport.ts @@ -0,0 +1,381 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import type * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +/** + * Kimi CLI executes shell commands through the ACP client's terminal + * capability (as Zed does): `terminal/create` spawns the command, + * `terminal/wait_for_exit` blocks until it exits, `terminal/output` reads the + * captured output, and `terminal/kill`/`terminal/release` stop and dispose it. + * This manager implements those handlers for one Kimi ACP session. + * + * Wire-shape note verified against Kimi CLI 0.37.2: the + * `terminal/wait_for_exit` response carries `exitCode`/`signal` at the TOP + * level (`WaitForTerminalExitResponse`). Nesting them under an `exitStatus` + * key makes Kimi read exit code -1 and mark the tool call failed. Only the + * `terminal/output` response nests them as `exitStatus`. + */ +export interface KimiAcpTerminalManager { + readonly handleCreateTerminal: ( + request: EffectAcpSchema.CreateTerminalRequest, + ) => Effect.Effect; + readonly handleTerminalOutput: ( + request: EffectAcpSchema.TerminalOutputRequest, + ) => Effect.Effect; + readonly handleTerminalWaitForExit: ( + request: EffectAcpSchema.WaitForTerminalExitRequest, + ) => Effect.Effect; + readonly handleTerminalKill: ( + request: EffectAcpSchema.KillTerminalRequest, + ) => Effect.Effect; + readonly handleTerminalRelease: ( + request: EffectAcpSchema.ReleaseTerminalRequest, + ) => Effect.Effect; + /** + * Kills every live terminal's process and settles its exit with a SIGTERM + * status, but keeps the terminal registered: per ACP the agent may still + * call `terminal/output`/`terminal/release` on a killed terminal. Used at + * turn interrupt to unblock an agent parked in `terminal/wait_for_exit`; + * `shutdown` remains the full-dispose session-stop path. Idempotent: + * already-exited or already-killed terminals are no-ops. + */ + readonly killAll: Effect.Effect; + /** Kills and disposes every terminal still open; used at session stop. */ + readonly shutdown: Effect.Effect; +} + +/** Kimi CLI 0.37.2 sends outputByteLimit 4 MiB; used when a create omits it. */ +const DEFAULT_OUTPUT_BYTE_LIMIT = 4 * 1024 * 1024; + +interface KimiTerminalExit { + readonly exitCode: number | null; + readonly signal: string | null; +} + +interface KimiTerminalOutputBuffer { + output: string; + outputBytes: number; + truncated: boolean; + readonly byteLimit: number; +} + +interface KimiPendingTerminalCreation { + readonly scope: Scope.Closeable; + killRequested: boolean; + disposeRequested: boolean; +} + +interface KimiTerminalState { + readonly scope: Scope.Closeable; + readonly buffer: KimiTerminalOutputBuffer; + readonly exit: Deferred.Deferred; + readonly drainFibers: ReadonlyArray>; + readonly kill: Effect.Effect; +} + +function utf8ByteLengthOfCodePoint(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** + * Appends decoded output while enforcing the ACP byte limit: when the buffer + * exceeds it, drop from the beginning at a character boundary and flag the + * output as truncated, as the protocol requires. + */ +function appendTerminalOutput(buffer: KimiTerminalOutputBuffer, text: string): void { + if (text.length === 0) { + return; + } + buffer.output += text; + buffer.outputBytes += Buffer.byteLength(text, "utf8"); + if (buffer.outputBytes <= buffer.byteLimit) { + return; + } + buffer.truncated = true; + let overBytes = buffer.outputBytes - buffer.byteLimit; + let dropUnits = 0; + let droppedBytes = 0; + while (overBytes > 0 && dropUnits < buffer.output.length) { + const codePoint = buffer.output.codePointAt(dropUnits) ?? 0; + const codePointBytes = utf8ByteLengthOfCodePoint(codePoint); + dropUnits += codePoint > 0xffff ? 2 : 1; + droppedBytes += codePointBytes; + overBytes -= codePointBytes; + } + buffer.output = buffer.output.slice(dropUnits); + buffer.outputBytes -= droppedBytes; +} + +const KILLED_BY_SIGNAL_PATTERN = /signal: '([A-Z0-9]+)'/; + +/** + * `ChildProcessHandle.exitCode` fails only when the process was terminated by + * a signal (POSIX; Windows kills surface as a normal exit code). Recover the + * signal name for the ACP exit status so a killed command reports as killed + * rather than exit 0. The wire schema requires exitCode >= 0, so the fallback + * when the signal name cannot be parsed is a generic SIGTERM, never -1. + */ +function exitFromSignalFailure(error: PlatformError.PlatformError): KimiTerminalExit { + const text = `${error.message} ${String(error.cause ?? "")}`; + const signal = KILLED_BY_SIGNAL_PATTERN.exec(text)?.[1]; + return { exitCode: null, signal: signal ?? "SIGTERM" }; +} + +function unknownTerminalError(method: string, terminalId: string): EffectAcpErrors.AcpRequestError { + return EffectAcpErrors.AcpRequestError.internalError( + `Unknown or released terminal '${terminalId}'.`, + undefined, + { method }, + ); +} + +export const makeKimiAcpTerminalManager = (input: { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; +}): Effect.Effect => + Effect.sync(() => { + const terminals = new Map(); + const pendingCreations = new Map(); + let nextTerminalId = 0; + + const requireTerminal = ( + method: string, + terminalId: string, + ): Effect.Effect => { + const state = terminals.get(terminalId); + return state ? Effect.succeed(state) : Effect.fail(unknownTerminalError(method, terminalId)); + }; + + const disposeTerminal = (terminalId: string, state: KimiTerminalState): Effect.Effect => + Effect.gen(function* () { + terminals.delete(terminalId); + // Closing the terminal scope interrupts the drain fibers and runs the + // spawner finalizer, which kills the process (group) if it is still + // running and waits for it to exit. + yield* Effect.ignore(Scope.close(state.scope, Exit.void)); + // The scope close may have interrupted the exit watcher before it + // resolved; settle the deferred so an in-flight wait_for_exit cannot + // hang. Release kills the process, so a signal exit is honest. A + // no-op when the process exit already resolved it. + yield* Deferred.succeed(state.exit, { exitCode: null, signal: "SIGTERM" }); + }); + + const handleCreateTerminal: KimiAcpTerminalManager["handleCreateTerminal"] = (request) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const terminalId = `term-${++nextTerminalId}`; + const scope = yield* Scope.make(); + const pendingCreation: KimiPendingTerminalCreation = { + scope, + killRequested: false, + disposeRequested: false, + }; + pendingCreations.set(terminalId, pendingCreation); + const env = request.env + ? Object.fromEntries(request.env.map((entry) => [entry.name, entry.value])) + : undefined; + // Kimi sends an absolute command path (its Git Bash wrapper on + // Windows), so no shell resolution is involved. + const handleExit = yield* Effect.exit( + restore( + input.childProcessSpawner + .spawn( + ChildProcess.make(request.command, request.args ?? [], { + ...(request.cwd ? { cwd: request.cwd } : {}), + ...(env ? { env, extendEnv: true } : {}), + stdin: "ignore", + // Kill escalation for release/shutdown of commands that ignore + // the default termination signal. + forceKillAfter: "5 seconds", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.internalError( + `Failed to spawn terminal command '${request.command}'.`, + undefined, + { method: "terminal/create", cause }, + ), + ), + ), + ), + ); + if (Exit.isFailure(handleExit)) { + pendingCreations.delete(terminalId); + yield* Effect.ignore(Scope.close(scope, Exit.void)); + return yield* Effect.failCause(handleExit.cause); + } + const handle = handleExit.value; + + const buffer: KimiTerminalOutputBuffer = { + output: "", + outputBytes: 0, + truncated: false, + byteLimit: Math.max(0, request.outputByteLimit ?? DEFAULT_OUTPUT_BYTE_LIMIT), + }; + const exit = yield* Deferred.make(); + + // One streaming decoder per stream so interleaving cannot split a + // multi-byte character across decode calls. + const drainStream = (stream: typeof handle.stdout): Effect.Effect => + Effect.suspend(() => { + const decoder = new TextDecoder("utf-8"); + return Stream.runForEach(stream, (chunk) => + Effect.sync(() => + appendTerminalOutput(buffer, decoder.decode(chunk, { stream: true })), + ), + ).pipe( + Effect.ensuring(Effect.sync(() => appendTerminalOutput(buffer, decoder.decode()))), + Effect.ignore, + ); + }); + + const stdoutFiber = yield* drainStream(handle.stdout).pipe(Effect.forkIn(scope)); + const stderrFiber = yield* drainStream(handle.stderr).pipe(Effect.forkIn(scope)); + yield* handle.exitCode.pipe( + Effect.matchEffect({ + onSuccess: (exitCode) => Deferred.succeed(exit, { exitCode, signal: null }), + onFailure: (error) => Deferred.succeed(exit, exitFromSignalFailure(error)), + }), + Effect.forkIn(scope), + ); + + const terminalState: KimiTerminalState = { + scope, + buffer, + exit, + drainFibers: [stdoutFiber, stderrFiber], + kill: Effect.ignore(handle.kill()), + }; + terminals.set(terminalId, terminalState); + pendingCreations.delete(terminalId); + if (pendingCreation.disposeRequested) { + yield* disposeTerminal(terminalId, terminalState); + return yield* EffectAcpErrors.AcpRequestError.internalError( + "Terminal creation was cancelled because the Kimi session stopped.", + undefined, + { method: "terminal/create" }, + ); + } + if (pendingCreation.killRequested) { + yield* Deferred.succeed(exit, { exitCode: null, signal: "SIGTERM" }); + yield* terminalState.kill; + } + return { terminalId } satisfies EffectAcpSchema.CreateTerminalResponse; + }), + ); + + const handleTerminalOutput: KimiAcpTerminalManager["handleTerminalOutput"] = (request) => + Effect.gen(function* () { + const state = yield* requireTerminal("terminal/output", request.terminalId); + const exited = yield* Deferred.isDone(state.exit); + if (!exited) { + return { + output: state.buffer.output, + truncated: state.buffer.truncated, + } satisfies EffectAcpSchema.TerminalOutputResponse; + } + // The process exited; wait for the drain fibers to flush the final + // chunks (deterministic: the pipes close at exit) so the reported + // output is complete. + yield* Effect.forEach(state.drainFibers, (fiber) => Effect.ignore(Fiber.join(fiber)), { + discard: true, + }); + const exitStatus = yield* Deferred.await(state.exit); + return { + output: state.buffer.output, + truncated: state.buffer.truncated, + exitStatus, + } satisfies EffectAcpSchema.TerminalOutputResponse; + }); + + const handleTerminalWaitForExit: KimiAcpTerminalManager["handleTerminalWaitForExit"] = ( + request, + ) => + Effect.gen(function* () { + const state = yield* requireTerminal("terminal/wait_for_exit", request.terminalId); + const exit = yield* Deferred.await(state.exit); + // Top-level exitCode/signal, never nested under exitStatus: Kimi reads + // a nested shape as exit code -1 and fails the tool call. + return { + ...(exit.exitCode !== null ? { exitCode: exit.exitCode } : {}), + ...(exit.signal !== null ? { signal: exit.signal } : {}), + } satisfies EffectAcpSchema.WaitForTerminalExitResponse; + }); + + const handleTerminalKill: KimiAcpTerminalManager["handleTerminalKill"] = (request) => + Effect.gen(function* () { + const state = yield* requireTerminal("terminal/kill", request.terminalId); + // Idempotent: killing an already-exited process is not an error. + yield* state.kill; + return {} satisfies EffectAcpSchema.KillTerminalResponse; + }); + + const handleTerminalRelease: KimiAcpTerminalManager["handleTerminalRelease"] = (request) => + Effect.gen(function* () { + const state = yield* requireTerminal("terminal/release", request.terminalId); + yield* disposeTerminal(request.terminalId, state); + return {} satisfies EffectAcpSchema.ReleaseTerminalResponse; + }); + + const killAll: Effect.Effect = Effect.suspend(() => + Effect.sync(() => { + for (const pending of pendingCreations.values()) { + pending.killRequested = true; + } + return Array.from(terminals.values()); + }).pipe( + Effect.flatMap((snapshot) => + Effect.forEach( + snapshot, + (state) => + // Settle the exit first so a concurrent wait_for_exit observes the + // SIGTERM status deterministically; Deferred.succeed is a no-op when + // the process already exited. The kill then stops the process (a + // no-op for an already-dead one) while the entry stays readable. + Effect.gen(function* () { + yield* Deferred.succeed(state.exit, { exitCode: null, signal: "SIGTERM" }); + yield* state.kill; + }), + { discard: true }, + ), + ), + ), + ); + + const shutdown: Effect.Effect = Effect.suspend(() => + Effect.sync(() => { + for (const pending of pendingCreations.values()) { + pending.disposeRequested = true; + } + return Array.from(terminals.entries()); + }).pipe( + Effect.flatMap((snapshot) => + Effect.forEach(snapshot, ([terminalId, state]) => disposeTerminal(terminalId, state), { + discard: true, + }), + ), + ), + ); + + return { + handleCreateTerminal, + handleTerminalOutput, + handleTerminalWaitForExit, + handleTerminalKill, + handleTerminalRelease, + killAll, + shutdown, + } satisfies KimiAcpTerminalManager; + }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..622b48944bd8 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { KimiDriver, type KimiDriverEnv } from "./Drivers/KimiDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | KimiDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + it("matches kimi-cli's credential file shape", () => { + const json = buildKimiCredentialsJson( + { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + scope: "kimi-code", + token_type: "Bearer", + }, + 1_000_000, + ); + expect(JSON.parse(json)).toEqual({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_at: 1_900, + scope: "kimi-code", + token_type: "Bearer", + expires_in: 900, + }); + expect(json.endsWith("\n")).toBe(true); + }); + + it("fills kimi-cli defaults for optional token fields", () => { + const parsed = JSON.parse( + buildKimiCredentialsJson({ access_token: "access-1", refresh_token: "refresh-1" }, 0), + ); + expect(parsed.expires_in).toBe(900); + expect(parsed.scope).toBe("kimi-code"); + expect(parsed.token_type).toBe("Bearer"); + }); +}); + +describe("resolveKimiCodeHome", () => { + it("prefers a configured home path over the CLI default", () => { + expect(resolveKimiCodeHome("/data/kimi-work")).toBe("/data/kimi-work"); + expect(resolveKimiCodeHome(" ")).toContain(".kimi-code"); + expect(resolveKimiCodeHome(undefined)).toContain(".kimi-code"); + }); +}); + +describe("resolveKimiSignInHomePath", () => { + it.effect("prefers the targeted instance's homePath", () => + Effect.gen(function* () { + const settings = decodeServerSettings({ + providers: { kimi: { homePath: "/legacy/home" } }, + providerInstances: { + kimi_work: { driver: "kimi", config: { homePath: "/work/home" } }, + }, + }); + expect(yield* resolveKimiSignInHomePath(settings, ProviderInstanceId.make("kimi_work"))).toBe( + "/work/home", + ); + }), + ); + + it.effect("uses the legacy home only for the synthesized default Kimi instance", () => + Effect.gen(function* () { + const settings = decodeServerSettings({ + providers: { kimi: { homePath: "/legacy/home" } }, + }); + expect(yield* resolveKimiSignInHomePath(settings, undefined)).toBe("/legacy/home"); + expect(yield* resolveKimiSignInHomePath(settings, ProviderInstanceId.make("kimi"))).toBe( + "/legacy/home", + ); + }), + ); + + it.effect("rejects missing and non-Kimi explicit instances", () => + Effect.gen(function* () { + const settings = decodeServerSettings({ + providerInstances: { + codex_work: { driver: "codex", config: {} }, + }, + }); + const missing = yield* resolveKimiAuthTarget( + settings, + ProviderInstanceId.make("kimi_missing"), + ).pipe(Effect.flip); + const wrongDriver = yield* resolveKimiAuthTarget( + settings, + ProviderInstanceId.make("codex_work"), + ).pipe(Effect.flip); + + expect(missing).toBeInstanceOf(KimiAuthInstanceInvalidError); + expect(missing.issue).toBe("not-found"); + expect(wrongDriver).toBeInstanceOf(KimiAuthInstanceInvalidError); + expect(wrongDriver.issue).toBe("wrong-driver"); + }), + ); + + it.effect("returns undefined when no home path is configured anywhere", () => + Effect.gen(function* () { + expect(yield* resolveKimiSignInHomePath(decodeServerSettings({}), undefined)).toBe(undefined); + }), + ); +}); + +interface RecordedRequest { + readonly url: string; + readonly params: URLSearchParams; +} + +type MockResponse = { readonly status: number; readonly body: unknown }; + +const makeKimiOAuthHttpLayer = ( + requests: Array, + respond: (url: string, callIndex: number) => MockResponse, +) => { + let calls = 0; + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, params: new URLSearchParams(body) }); + const response = respond(request.url, calls); + calls += 1; + return HttpClientResponse.fromWeb( + request, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mock wire payloads are free-form test fixtures. + new Response(JSON.stringify(response.body), { + status: response.status, + headers: { "content-type": "application/json" }, + }), + ); + }), + ), + ); +}; + +const DEVICE_AUTHORIZATION_BODY = { + device_code: "device-code-1", + user_code: "ABCD-1234", + verification_uri: "https://auth.kimi.com/device", + verification_uri_complete: "https://auth.kimi.com/device?code=ABCD-1234", + expires_in: 600, + interval: 5, +}; + +it.layer(NodeServices.layer)("signInWithKimi", (it) => { + it.effect("emits verification, then writes the credential and completes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-oauth-" }); + + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url, callIndex) => { + if (url.includes("device_authorization")) { + return { status: 200, body: DEVICE_AUTHORIZATION_BODY }; + } + return callIndex < 2 + ? { status: 400, body: { error: "authorization_pending" } } + : { + status: 200, + body: { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + scope: "kimi-code", + token_type: "Bearer", + }, + }; + }); + + const collected = yield* signInWithKimi({ homePath: home }).pipe( + Stream.runCollect, + Effect.provide(httpLayer), + Effect.forkChild, + ); + // First poll waits the advertised interval; the second follows a + // pending answer. Two adjustments release both sleeps. + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("5 seconds"); + const events = yield* Fiber.join(collected); + + expect(events).toEqual([ + { + type: "verification", + verificationUri: "https://auth.kimi.com/device?code=ABCD-1234", + userCode: "ABCD-1234", + expiresInSeconds: 600, + }, + { type: "completed" }, + ]); + + const grants = requests.filter((request) => request.url.includes("/api/oauth/token")); + expect(grants).toHaveLength(2); + expect(grants[0]?.params.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:device_code", + ); + expect(grants[0]?.params.get("device_code")).toBe("device-code-1"); + + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting on the raw credential file kimi-cli reads. + const credentials = JSON.parse( + yield* fs.readFileString(path.join(home, "credentials", "kimi-code.json")), + ); + expect(credentials.access_token).toBe("access-1"); + expect(credentials.refresh_token).toBe("refresh-1"); + expect(credentials.token_type).toBe("Bearer"); + }), + ); + + it.effect("fails with `denied` when the user rejects the sign-in", () => + Effect.gen(function* () { + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { status: 400, body: { error: "access_denied" } }, + ); + + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthDeniedError); + }), + ); + + it.effect("stops at the deadline without polling after an oversized interval", () => + Effect.gen(function* () { + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { + status: 200, + body: { ...DEVICE_AUTHORIZATION_BODY, expires_in: 1, interval: 5 }, + } + : { status: 500, body: { error: "unexpected_poll" } }, + ); + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + + yield* TestClock.adjust("1 second"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthExpiredError); + expect(requests.filter((request) => request.url.includes("/api/oauth/token"))).toHaveLength( + 0, + ); + }), + ); + + it.effect("expires by monotonic time after the wall clock moves backward", () => + Effect.gen(function* () { + const testClock = yield* TestClock.testClockWith(Effect.succeed); + const wallClockMoved = yield* Deferred.make(); + const requests: Array = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const isDeviceAuthorization = request.url.includes("device_authorization"); + requests.push({ url: request.url, params: new URLSearchParams() }); + if (!isDeviceAuthorization) { + yield* testClock.setTime(-10_000); + yield* Deferred.succeed(wallClockMoved, undefined); + } + return HttpClientResponse.fromWeb( + request, + new Response( + // @effect-diagnostics-next-line preferSchemaOverJson:off - mock wire payloads are free-form test fixtures. + JSON.stringify( + isDeviceAuthorization + ? { ...DEVICE_AUTHORIZATION_BODY, expires_in: 2, interval: 1 } + : { error: "authorization_pending" }, + ), + { + status: isDeviceAuthorization ? 200 : 400, + headers: { "content-type": "application/json" }, + }, + ), + ); + }), + ), + ); + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + + yield* TestClock.adjust("1 second"); + yield* Deferred.await(wallClockMoved); + yield* TestClock.adjust("1 second"); + const completed = outcome.pollUnsafe(); + + expect(completed).toBeDefined(); + expect(yield* Fiber.join(outcome)).toBeInstanceOf(KimiAuthExpiredError); + expect(requests.filter((request) => request.url.includes("/api/oauth/token"))).toHaveLength( + 1, + ); + }), + ); + + it.effect("keeps raw OAuth wire failures out of stable error context", () => + Effect.gen(function* () { + const rawDescription = "x".repeat(10_000); + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { + status: 400, + body: { error: "vendor_secret_code", error_description: rawDescription }, + }, + ); + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthRequestError); + expect(error._tag).toBe("KimiAuthRequestError"); + if (error._tag === "KimiAuthRequestError") { + expect(error.operation).toBe("token-poll"); + expect(error.status).toBe(400); + expect(error.oauthErrorCode).toBeUndefined(); + expect(error).not.toHaveProperty("detail"); + expect((error.cause as { errorDescription?: string }).errorDescription).toBe( + rawDescription, + ); + } + }), + ); + + it.effect("fails with `expired` when the device authorization lapses", () => + Effect.gen(function* () { + const requests: Array = []; + const httpLayer = makeKimiOAuthHttpLayer(requests, (url) => + url.includes("device_authorization") + ? { status: 200, body: DEVICE_AUTHORIZATION_BODY } + : { status: 400, body: { error: "expired_token" } }, + ); + + const outcome = yield* signInWithKimi({}).pipe( + Stream.runCollect, + Effect.flip, + Effect.provide(httpLayer), + Effect.forkChild, + ); + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(outcome); + + expect(error).toBeInstanceOf(KimiAuthExpiredError); + }), + ); +}); + +it.layer(NodeServices.layer)("writeKimiCredentials", (it) => { + it.effect("writes atomically into the credentials directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-creds-" }); + + const credentialsPath = yield* writeKimiCredentials(home, { + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 900, + }); + + expect(credentialsPath).toBe(path.join(home, "credentials", "kimi-code.json")); + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting on the raw credential file kimi-cli reads. + const parsed = JSON.parse(yield* fs.readFileString(credentialsPath)); + expect(parsed.access_token).toBe("access-1"); + + // No stray temp files left behind. + const entries = yield* fs.readDirectory(path.join(home, "credentials")); + expect(entries).toEqual(["kimi-code.json"]); + }), + ); + + it.effect("uses unique temporary files for concurrent credential writes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-concurrent-creds-" }); + const temporaryPaths = yield* Ref.make>([]); + const recordingFileSystem: FileSystem.FileSystem = { + ...fs, + writeFileString: (filePath) => + Ref.update(temporaryPaths, (current) => [...current, filePath]), + rename: () => Effect.void, + }; + + const paths = yield* Effect.all( + [ + writeKimiCredentials(home, { + access_token: "access-1", + refresh_token: "refresh-1", + }), + writeKimiCredentials(home, { + access_token: "access-2", + refresh_token: "refresh-2", + }), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.provideService(FileSystem.FileSystem, recordingFileSystem)); + const observedTemporaryPaths = yield* Ref.get(temporaryPaths); + + expect(paths[0]).toBe(paths[1]); + expect(observedTemporaryPaths).toHaveLength(2); + expect(new Set(observedTemporaryPaths).size).toBe(2); + }), + ); + + it.effect("removes only the targeted instance credential file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-kimi-sign-out-" }); + const credentialsPath = yield* writeKimiCredentials(home, { + access_token: "access-1", + refresh_token: "refresh-1", + }); + const siblingPath = path.join(home, "credentials", "keep.txt"); + yield* fs.writeFileString(siblingPath, "keep"); + + expect(yield* removeKimiCredentials(home)).toBe(credentialsPath); + expect(yield* fs.exists(credentialsPath)).toBe(false); + expect(yield* fs.readFileString(siblingPath)).toBe("keep"); + }), + ); +}); diff --git a/apps/server/src/provider/kimi/KimiOAuth.ts b/apps/server/src/provider/kimi/KimiOAuth.ts new file mode 100644 index 000000000000..cd58e7ebe593 --- /dev/null +++ b/apps/server/src/provider/kimi/KimiOAuth.ts @@ -0,0 +1,380 @@ +/** + * KimiOAuth — server-side "Sign in with Kimi" via the OAuth 2.0 Device + * Authorization Grant (RFC 8628) against Moonshot's auth service. + * + * The flow produces exactly the credential file the Kimi CLI writes for + * itself (`$KIMI_CODE_HOME/credentials/kimi-code.json`), so after a + * successful sign-in `kimi` — and therefore the Kimi provider — is + * authenticated without ever opening a terminal. Token refresh stays with + * the CLI, which already refreshes on use with cross-process locking. + * + * Endpoints, client id, and the credential format mirror kimi-cli + * (`src/kimi_cli/auth/oauth.py`). + * + * @module provider/kimi/KimiOAuth + */ +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; + +import { + KimiAuthDeniedError, + type KimiAuthError, + KimiAuthExpiredError, + KimiAuthInstanceInvalidError, + KimiAuthRequestError, + KimiCredentialRemoveError, + KimiCredentialWriteError, + type KimiAuthSignInEvent, + KimiOAuthErrorCode, + KimiSettings, + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const DEVICE_AUTHORIZATION_PATH = "/api/oauth/device_authorization"; +const TOKEN_PATH = "/api/oauth/token"; +// Public device-flow client id shipped inside kimi-cli; not a secret. +const KIMI_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const KIMI_CODE_HOME_DIR_NAME = ".kimi-code"; +const CREDENTIALS_DIR_NAME = "credentials"; +const CREDENTIALS_FILE_NAME = "kimi-code.json"; +// RFC 8628 defaults: poll every 5s unless told otherwise, and never poll +// past the device authorization's own expiry (capped defensively). +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_EXPIRES_IN_SECONDS = 600; +const MAX_SIGN_IN_DURATION = Duration.minutes(15); +const MAX_SIGN_IN_DURATION_NANOS = Duration.toNanosUnsafe(MAX_SIGN_IN_DURATION); + +/** Where the Kimi CLI resolves its data root, honoring a per-instance homePath. */ +export function resolveKimiCodeHome(homePath: string | null | undefined): string { + const trimmed = homePath?.trim(); + return trimmed ? expandHomePath(trimmed) : `${NodeOS.homedir()}/${KIMI_CODE_HOME_DIR_NAME}`; +} + +const decodeKimiSettingsExit = Schema.decodeUnknownExit(KimiSettings); +const KIMI_DRIVER = ProviderDriverKind.make("kimi"); +const DEFAULT_KIMI_INSTANCE_ID = defaultInstanceIdForDriver(KIMI_DRIVER); + +export interface KimiAuthTarget { + readonly instanceId: ProviderInstanceId; + readonly homePath: string | undefined; +} + +export const resolveKimiAuthTarget = Effect.fn("kimi.oauth.resolve_target")(function* ( + settings: ServerSettings, + instanceId: ProviderInstanceId | undefined, +) { + const targetInstanceId = instanceId ?? DEFAULT_KIMI_INSTANCE_ID; + const instance = settings.providerInstances[targetInstanceId]; + if (instance !== undefined) { + if (instance.driver !== KIMI_DRIVER) { + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "wrong-driver", + }); + } + const decoded = decodeKimiSettingsExit(instance.config ?? {}); + if (Exit.isFailure(decoded)) { + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "invalid-settings", + cause: decoded.cause, + }); + } + return { + instanceId: targetInstanceId, + homePath: decoded.value.homePath.trim() || undefined, + } satisfies KimiAuthTarget; + } + if (targetInstanceId !== DEFAULT_KIMI_INSTANCE_ID) { + return yield* new KimiAuthInstanceInvalidError({ + instanceId: targetInstanceId, + issue: "not-found", + }); + } + return { + instanceId: DEFAULT_KIMI_INSTANCE_ID, + homePath: settings.providers.kimi.homePath.trim() || undefined, + } satisfies KimiAuthTarget; +}); + +export const resolveKimiSignInHomePath = Effect.fn("kimi.oauth.resolve_sign_in_home")(function* ( + settings: ServerSettings, + instanceId: ProviderInstanceId | undefined, +) { + return (yield* resolveKimiAuthTarget(settings, instanceId)).homePath; +}); + +function resolveOAuthHost(): string { + const override = + process.env.KIMI_CODE_OAUTH_HOST?.trim() || process.env.KIMI_OAUTH_HOST?.trim() || ""; + return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, ""); +} + +const DeviceAuthorizationResponse = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.optional(Schema.String), + verification_uri: Schema.optional(Schema.String), + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}); + +const TokenPollResponse = Schema.Struct({ + access_token: Schema.optional(Schema.String), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}); +type TokenPollResponse = typeof TokenPollResponse.Type; +const isKimiOAuthErrorCode = Schema.is(KimiOAuthErrorCode); + +const postForm = Effect.fn("kimi.oauth.post_form")(function* ( + path: string, + params: Record, +) { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.post(`${resolveOAuthHost()}${path}`).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + ); + return response; +}); + +const requestDeviceAuthorization = Effect.fn("kimi.oauth.device_authorization")(function* () { + const response = yield* postForm(DEVICE_AUTHORIZATION_PATH, { + client_id: KIMI_OAUTH_CLIENT_ID, + }).pipe( + Effect.mapError( + (cause) => + new KimiAuthRequestError({ + operation: "device-authorization-request", + cause, + }), + ), + ); + if (response.status !== 200) { + return yield* new KimiAuthRequestError({ + operation: "device-authorization-request", + status: response.status, + }); + } + return yield* HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)(response).pipe( + Effect.mapError( + (cause) => + new KimiAuthRequestError({ + operation: "device-authorization-response", + cause, + }), + ), + ); +}); + +const pollToken = Effect.fn("kimi.oauth.poll_token")( + function* (deviceCode: string) { + const response = yield* postForm(TOKEN_PATH, { + client_id: KIMI_OAUTH_CLIENT_ID, + device_code: deviceCode, + grant_type: DEVICE_CODE_GRANT, + }); + const body = yield* HttpClientResponse.schemaBodyJson(TokenPollResponse)(response); + return { status: response.status, body }; + }, + Effect.mapError( + (cause) => + new KimiAuthRequestError({ + operation: "token-poll", + cause, + }), + ), +); + +/** kimi-cli's on-disk credential shape (`credentials/kimi-code.json`). */ +export function buildKimiCredentialsJson( + token: Pick< + TokenPollResponse, + "access_token" | "refresh_token" | "expires_in" | "scope" | "token_type" + >, + nowEpochMs: number, +): string { + const expiresIn = token.expires_in ?? 900; + return `${JSON.stringify( + { + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: nowEpochMs / 1000 + expiresIn, + scope: token.scope ?? "kimi-code", + token_type: token.token_type ?? "Bearer", + expires_in: expiresIn, + }, + null, + 2, + )}\n`; +} + +/** + * Persist the credential exactly where kimi-cli looks for it, atomically + * (tmp → rename) with owner-only permissions, matching the CLI's own writes. + */ +export const writeKimiCredentials = Effect.fn("kimi.oauth.write_credentials")(function* ( + homePath: string | null | undefined, + token: TokenPollResponse, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowEpochMs = yield* Clock.currentTimeMillis; + + const credentialsDir = path.join(resolveKimiCodeHome(homePath), CREDENTIALS_DIR_NAME); + const credentialsPath = path.join(credentialsDir, CREDENTIALS_FILE_NAME); + const temporaryPath = `${credentialsPath}.${process.pid}.${nowEpochMs}.${NodeCrypto.randomUUID()}.tmp`; + + yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(credentialsDir, { recursive: true, mode: 0o700 }); + yield* fileSystem.writeFileString(temporaryPath, buildKimiCredentialsJson(token, nowEpochMs), { + mode: 0o600, + }); + yield* fileSystem.rename(temporaryPath, credentialsPath); + }).pipe( + Effect.tapError(() => Effect.ignore(fileSystem.remove(temporaryPath, { force: true }))), + Effect.mapError((cause) => new KimiCredentialWriteError({ credentialsPath, cause })), + ); + + return credentialsPath; +}); + +export const removeKimiCredentials = Effect.fn("kimi.oauth.remove_credentials")(function* ( + homePath: string | null | undefined, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const credentialsPath = path.join( + resolveKimiCodeHome(homePath), + CREDENTIALS_DIR_NAME, + CREDENTIALS_FILE_NAME, + ); + yield* fileSystem + .remove(credentialsPath, { force: true }) + .pipe(Effect.mapError((cause) => new KimiCredentialRemoveError({ credentialsPath, cause }))); + return credentialsPath; +}); + +export interface KimiSignInInput { + /** KIMI_CODE_HOME override from the target provider instance, if any. */ + readonly homePath?: string | null | undefined; +} + +/** + * Run one device-flow sign-in. Emits `verification` as soon as the user has + * something to open, then polls until approval and emits `completed` after + * the credential file is written. Interrupting the stream abandons the + * attempt without side effects. + */ +export function signInWithKimi( + input: KimiSignInInput, +): Stream.Stream< + KimiAuthSignInEvent, + KimiAuthError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path +> { + return Stream.unwrap( + Effect.gen(function* () { + const authorization = yield* requestDeviceAuthorization(); + const verificationUri = + authorization.verification_uri_complete?.trim() || + authorization.verification_uri?.trim() || + ""; + if (!verificationUri) { + return Stream.fail( + new KimiAuthRequestError({ + operation: "device-authorization-response", + }), + ); + } + const expiresInSeconds = authorization.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS; + const userCode = authorization.user_code?.trim(); + + const verificationEvent: KimiAuthSignInEvent = { + type: "verification", + verificationUri, + ...(userCode ? { userCode } : {}), + expiresInSeconds, + }; + + const completion = Effect.gen(function* () { + const startedAtNanos = yield* Clock.monotonicTimeNanos; + const expiresInNanos = Duration.toNanosUnsafe( + Duration.seconds(Math.max(0, expiresInSeconds)), + ); + const deadlineNanos = + startedAtNanos + + (expiresInNanos < MAX_SIGN_IN_DURATION_NANOS + ? expiresInNanos + : MAX_SIGN_IN_DURATION_NANOS); + let intervalSeconds = Math.max(1, authorization.interval ?? DEFAULT_POLL_INTERVAL_SECONDS); + + while ((yield* Clock.monotonicTimeNanos) < deadlineNanos) { + const remainingNanos = deadlineNanos - (yield* Clock.monotonicTimeNanos); + const intervalNanos = Duration.toNanosUnsafe(Duration.seconds(intervalSeconds)); + yield* Effect.sleep( + Duration.nanos(intervalNanos < remainingNanos ? intervalNanos : remainingNanos), + ); + if ((yield* Clock.monotonicTimeNanos) >= deadlineNanos) { + break; + } + const poll = yield* pollToken(authorization.device_code); + if (poll.status === 200 && poll.body.access_token) { + yield* writeKimiCredentials(input.homePath, poll.body); + return { type: "completed" } as const satisfies KimiAuthSignInEvent; + } + switch (poll.body.error) { + case "authorization_pending": + continue; + case "slow_down": + intervalSeconds += 5; + continue; + case "access_denied": + return yield* new KimiAuthDeniedError(); + case "expired_token": + return yield* new KimiAuthExpiredError(); + default: + return yield* new KimiAuthRequestError({ + operation: "token-poll", + status: poll.status, + ...(isKimiOAuthErrorCode(poll.body.error) + ? { oauthErrorCode: poll.body.error } + : {}), + cause: { + error: poll.body.error, + errorDescription: poll.body.error_description, + }, + }); + } + } + return yield* new KimiAuthExpiredError(); + }); + + return Stream.concat(Stream.make(verificationEvent), Stream.fromEffect(completion)); + }), + ); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 02a367c08792..51dd4bd0cafa 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -28,6 +28,8 @@ import { ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, + type ServerSettings as ServerSettingsValue, + ServerSettingsError, ThreadId, WS_METHODS, WsRpcGroup, @@ -56,6 +58,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -4389,6 +4392,82 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes Kimi sign-out to the targeted home and refreshes that instance", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-kimi-sign-out-rpc-" }); + const credentialsDir = path.join(homePath, "credentials"); + const credentialsPath = path.join(credentialsDir, "kimi-code.json"); + yield* fs.makeDirectory(credentialsDir, { recursive: true }); + yield* fs.writeFileString(credentialsPath, "credential"); + const instanceId = ProviderInstanceId.make("kimi_work"); + const refreshed = yield* Ref.make>([]); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("kimi"), + enabled: true, + config: { homePath }, + }, + }, + } satisfies ServerSettingsValue; + yield* buildAppUnderTest({ + layers: { + serverSettings: { getSettings: Effect.succeed(settings) }, + providerRegistry: { + refreshInstance: (targetInstanceId) => + Ref.update(refreshed, (current) => [...current, targetInstanceId]).pipe( + Effect.as([]), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.kimiAuthSignOut]({ instanceId }), + ); + + assert.deepEqual(result, { type: "completed" }); + assert.isFalse(yield* fs.exists(credentialsPath)); + assert.deepEqual(yield* Ref.get(refreshed), [instanceId]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("fails Kimi sign-in when provider settings cannot be loaded", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-file", + cause: new Error("settings unavailable"), + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.kimiAuthSignIn]({}).pipe(Stream.runCollect, Effect.flip), + ), + ); + + assert.equal(error._tag, "KimiAuthRequestError"); + if (error._tag === "KimiAuthRequestError") { + assert.equal(error.operation, "provider-settings"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/textGeneration/KimiTextGeneration.ts b/apps/server/src/textGeneration/KimiTextGeneration.ts new file mode 100644 index 000000000000..831af8ff6aa0 --- /dev/null +++ b/apps/server/src/textGeneration/KimiTextGeneration.ts @@ -0,0 +1,282 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { type KimiSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getProviderOptionStringSelectionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + advertisedKimiModelIdsFromSessionSetup, + applyKimiAcpModelSelection, + applyKimiAcpThinkingSelection, + currentKimiModelIdFromSessionSetup, + findKimiThinkingConfigOption, + kimiSessionHasModelConfigOption, + makeKimiAcpRuntime, + resolveKimiAcpBaseModelId, +} from "../provider/acp/KimiAcpSupport.ts"; + +const KIMI_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeKimiTextGeneration = Effect.fn("makeKimiTextGeneration")(function* ( + kimiSettings: KimiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runKimiJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveKimiAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeKimiAcpRuntime({ + kimiSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* applyKimiAcpModelSelection({ + runtime, + currentModelId: currentKimiModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + advertisedModelIds: advertisedKimiModelIdsFromSessionSetup(started.sessionSetupResult), + hasModelConfigOption: kimiSessionHasModelConfigOption(started.sessionSetupResult), + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kimi ACP base model for text generation.", + cause, + }), + }); + const configOptions = yield* runtime.getConfigOptions; + const thinkingConfig = findKimiThinkingConfigOption(configOptions); + yield* applyKimiAcpThinkingSelection({ + runtime, + configOptions, + requestedValue: thinkingConfig + ? getProviderOptionStringSelectionValue(modelSelection.options, thinkingConfig.id) + : undefined, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Kimi ACP thinking level for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(KIMI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Kimi ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kimi ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Kimi ACP request was cancelled." + : "Kimi CLI returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kimi CLI returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Kimi ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("KimiTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runKimiJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("KimiTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runKimiJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("KimiTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runKimiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("KimiTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runKimiJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 11c659e28a70..605d7a40676f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,8 +3,10 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -51,6 +53,7 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + KimiAuthRequestError, RpcClientId, EnvironmentAuthorizationError, ThreadId, @@ -62,7 +65,12 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; -import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; +import { + HttpClient, + HttpRouter, + HttpServerRequest, + HttpServerRespondable, +} from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -83,6 +91,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as KimiOAuth from "./provider/kimi/KimiOAuth.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -463,6 +472,11 @@ const makeWsRpcLayer = ( ); const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; + // Captured for the Kimi sign-in stream, which runs the OAuth device + // flow over HTTP and writes the CLI credential file. + const kimiSignInHttpClient = yield* HttpClient.HttpClient; + const kimiSignInFileSystem = yield* FileSystem.FileSystem; + const kimiSignInPath = yield* Path.Path; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map( (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, @@ -1740,6 +1754,59 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.kimiAuthSignIn]: (input) => + observeRpcStreamEffect( + WS_METHODS.kimiAuthSignIn, + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new KimiAuthRequestError({ + operation: "provider-settings", + cause, + }), + ), + ); + const target = yield* KimiOAuth.resolveKimiAuthTarget(settings, input.instanceId); + return KimiOAuth.signInWithKimi({ homePath: target.homePath }).pipe( + // A fresh credential flips the probe to authenticated; refresh + // eagerly so the UI reflects the sign-in without waiting for + // the periodic health check. + Stream.tap((event) => + event.type === "completed" + ? providerRegistry.refreshInstance(target.instanceId).pipe(Effect.ignore) + : Effect.void, + ), + Stream.provideService(HttpClient.HttpClient, kimiSignInHttpClient), + Stream.provideService(FileSystem.FileSystem, kimiSignInFileSystem), + Stream.provideService(Path.Path, kimiSignInPath), + ); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.kimiAuthSignOut]: (input) => + observeRpcEffect( + WS_METHODS.kimiAuthSignOut, + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new KimiAuthRequestError({ + operation: "provider-settings", + cause, + }), + ), + ); + const target = yield* KimiOAuth.resolveKimiAuthTarget(settings, input.instanceId); + yield* KimiOAuth.removeKimiCredentials(target.homePath).pipe( + Effect.provideService(FileSystem.FileSystem, kimiSignInFileSystem), + Effect.provideService(Path.Path, kimiSignInPath), + ); + yield* providerRegistry.refreshInstance(target.instanceId).pipe(Effect.ignore); + return { type: "completed" } as const; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.pullRequestsList]: (input) => observeRpcEffect(WS_METHODS.pullRequestsList, pullRequests.list(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..8dd07c103fd2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,21 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const KimiIcon: Icon = ({ className, ...props }) => ( + + {/* Stylized "K" mark: upright stem plus two angled strokes, with the + upper arm ending in Kimi's dot accent. */} + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..504c80be3cc0 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, KimiIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("kimi")]: KimiIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/KimiSignInControl.test.tsx b/apps/web/src/components/settings/KimiSignInControl.test.tsx new file mode 100644 index 000000000000..81b7c91cae18 --- /dev/null +++ b/apps/web/src/components/settings/KimiSignInControl.test.tsx @@ -0,0 +1,133 @@ +import type { ReactElement } from "react"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const state = vi.hoisted(() => ({ + atomCalls: [] as Array, + values: new Map(), +})); +const commands = vi.hoisted(() => ({ + signIn: vi.fn(), + signOut: vi.fn(), +})); +const atoms = vi.hoisted(() => ({ + signIn: Symbol("kimiSignIn"), + signOut: Symbol("kimiSignOut"), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => state.values.get(atom) ?? { status: "idle" }, +})); + +vi.mock("../../state/server", () => ({ + serverEnvironment: { + kimiSignIn: atoms.signIn, + kimiSignOut: atoms.signOut, + kimiSignInStateAtom: (environmentId: EnvironmentId, instanceId: ProviderInstanceId) => { + state.atomCalls.push([environmentId, instanceId]); + return `${environmentId}:${instanceId}`; + }, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => (atom === atoms.signIn ? commands.signIn : commands.signOut), +})); + +import { KimiSignInControl } from "./KimiSignInControl"; + +const environmentId = EnvironmentId.make("environment-1"); +const personalId = ProviderInstanceId.make("kimi_personal"); +const workId = ProviderInstanceId.make("kimi_work"); + +function renderControl(instanceId: ProviderInstanceId, authenticated = false) { + hooks.beginRender(); + return KimiSignInControl({ authenticated, environmentId, instanceId }) as ReactElement< + Record + >; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("KimiSignInControl", () => { + beforeEach(() => { + hooks.reset(); + state.atomCalls = []; + state.values.clear(); + commands.signIn.mockReset().mockResolvedValue(undefined); + commands.signOut.mockReset().mockResolvedValue(undefined); + }); + + it("reads only the selected provider instance's sign-in state", () => { + state.values.set(`${environmentId}:${personalId}`, { + status: "waiting", + verificationUri: "https://auth.example/personal", + }); + + const personal = renderControl(personalId); + const work = renderControl(workId); + + expect(state.atomCalls).toEqual([ + [environmentId, personalId], + [environmentId, workId], + ]); + expect( + visitElements(personal, (element) => element.props.href === "https://auth.example/personal"), + ).not.toBeNull(); + const liveStatus = visitElements(personal, (element) => element.props.role === "status"); + expect(liveStatus?.props["aria-live"]).toBe("polite"); + expect(visitElements(work, (element) => element.props.href !== undefined)).toBeNull(); + }); + + it("announces sign-in failures as a polite live status", () => { + state.values.set(`${environmentId}:${workId}`, { + status: "failed", + message: "Kimi sign-in failed.", + }); + + const control = renderControl(workId); + const failure = visitElements( + control, + (element) => element.props.role === "status" && element.props["aria-live"] === "polite", + ); + + expect(failure).not.toBeNull(); + expect(failure?.props.children).toBe("Kimi sign-in failed."); + }); + + it("signs out the authenticated provider instance", async () => { + const control = renderControl(workId, true); + const button = visitElements(control, (element) => typeof element.props.onClick === "function"); + + (button?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.signOut).toHaveBeenCalledWith({ + environmentId, + input: { instanceId: workId }, + }); + expect(commands.signIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/KimiSignInControl.tsx b/apps/web/src/components/settings/KimiSignInControl.tsx new file mode 100644 index 000000000000..74c03a6d9513 --- /dev/null +++ b/apps/web/src/components/settings/KimiSignInControl.tsx @@ -0,0 +1,125 @@ +import type { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { CheckIcon, ExternalLinkIcon, LoaderIcon, LogOutIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; + +/** + * "Sign in with Kimi" affordance for Kimi provider instances. + * + * Runs the server-side OAuth device flow (`kimiAuth.signIn`) and renders its + * progress inline: a start button, then the verification link and user code + * while the server polls for approval, then a brief confirmation. The server + * refreshes the provider probe on success, so the surrounding card flips to + * authenticated on its own. + */ +export function KimiSignInControl({ + authenticated, + environmentId, + instanceId, +}: { + readonly authenticated: boolean; + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; +}) { + const signInState = useAtomValue( + serverEnvironment.kimiSignInStateAtom(environmentId, instanceId), + ); + const kimiSignIn = useAtomCommand(serverEnvironment.kimiSignIn, { reportFailure: false }); + const kimiSignOut = useAtomCommand(serverEnvironment.kimiSignOut, { reportFailure: false }); + const [isDispatching, setIsDispatching] = useState(false); + const dispatchingRef = useRef(false); + + const startSignIn = useCallback(() => { + if (dispatchingRef.current) return; + dispatchingRef.current = true; + setIsDispatching(true); + void kimiSignIn({ environmentId, input: { instanceId } }).finally(() => { + dispatchingRef.current = false; + setIsDispatching(false); + }); + }, [environmentId, instanceId, kimiSignIn]); + const startSignOut = useCallback(() => { + if (dispatchingRef.current) return; + dispatchingRef.current = true; + setIsDispatching(true); + void kimiSignOut({ environmentId, input: { instanceId } }).finally(() => { + dispatchingRef.current = false; + setIsDispatching(false); + }); + }, [environmentId, instanceId, kimiSignOut]); + + if (authenticated) { + return ( + + ); + } + + if (signInState.status === "waiting") { + return ( +
+ + + Approve sign-in in your browser + + {signInState.userCode ? ( + + Code:{" "} + + {signInState.userCode} + + + ) : null} +
+ ); + } + + return ( +
+ + {signInState.status === "failed" ? ( + + {signInState.message} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a663aa90990d..2d57374fe2a7 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -342,6 +342,11 @@ interface ProviderInstanceCardProps { * omit it. */ readonly headerAction?: ReactNode | undefined; + /** + * Driver-specific authentication affordance rendered under the auth + * summary row (e.g. Kimi's in-app "Sign in with Kimi" device flow). + */ + readonly authAction?: ReactNode | undefined; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -384,6 +389,7 @@ export function ProviderInstanceCard({ onUpdate, onDelete, headerAction, + authAction, hiddenModels, favoriteModels, modelOrder, @@ -704,6 +710,7 @@ export function ProviderInstanceCard({ {titleTailNode} {authRowNode} + {authAction ?
{authAction}
: null}