diff --git a/.changeset/steady-acp-support.md b/.changeset/steady-acp-support.md new file mode 100644 index 00000000..184d0203 --- /dev/null +++ b/.changeset/steady-acp-support.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add ACP server support and session-scoped MCP server configuration. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index f62143df..2fa794ea 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -66,6 +66,7 @@ "postinstall": "node scripts/postinstall.mjs" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.22.1", "@earendil-works/pi-tui": "^0.74.0", "@mariozechner/clipboard": "^0.3.2", "chalk": "^5.4.1", diff --git a/apps/kimi-code/src/acp/agent.ts b/apps/kimi-code/src/acp/agent.ts new file mode 100644 index 00000000..dad23c39 --- /dev/null +++ b/apps/kimi-code/src/acp/agent.ts @@ -0,0 +1,370 @@ +import { isAbsolute, resolve } from 'node:path'; + +import { + PROTOCOL_VERSION, + RequestError, + type Agent, + type AgentCapabilities, + type AgentSideConnection, + type AuthenticateRequest, + type AuthenticateResponse, + type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, + type ForkSessionRequest, + type ForkSessionResponse, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionConfigOption, + type SessionInfo, + type SessionModelState, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import { KimiHarness, log, type SessionSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; + +import { + authenticateAcpMethod, + createAuthMethods, + requireAcpAuthReady, +} from './auth-adapter'; +import { toAcpRequestError, toAcpSetModelRequestError } from './errors'; +import { acpMcpServersToKimiConfig } from './mcp-adapter'; +import { + createAcpModelConfigOptions, + MODEL_CONFIG_OPTION_ID, +} from './model-adapter'; +import { KimiAcpSession } from './session'; + +export interface KimiAcpAgentOptions { + readonly connection: AgentSideConnection; + readonly version: string; + readonly harness?: KimiHarness; +} + +type KimiAgentCapabilities = AgentCapabilities & { + readonly sessionCapabilities: NonNullable & { + readonly configOptions: Record; + }; +}; + +export class KimiAcpAgent implements Agent { + private readonly harness: KimiHarness; + private readonly sessions = new Map(); + + constructor(private readonly options: KimiAcpAgentOptions) { + this.harness = + options.harness ?? + new KimiHarness({ + identity: createKimiCodeHostIdentity(options.version), + uiMode: 'acp', + }); + } + + async initialize(params: InitializeRequest): Promise { + return { + protocolVersion: + params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION, + agentInfo: { + name: 'kimi-code', + title: 'Kimi Code', + version: this.options.version, + }, + agentCapabilities: createAgentCapabilities(), + authMethods: createAuthMethods(params.clientCapabilities), + }; + } + + async authenticate(params: AuthenticateRequest): Promise { + await authenticateAcpMethod(this.harness, params.methodId); + return {}; + } + + async listSessions(params: ListSessionsRequest): Promise { + try { + validateListSessionsRequest(params); + await this.harness.ensureConfigFile(); + await requireAcpAuthReady(this.harness); + const sessions = await this.harness.listSessions( + { workDir: params.cwd ?? undefined }, + ); + return { sessions: sessions.map(acpSessionInfoFromSummary) }; + } catch (error) { + throw toAcpRequestError(error); + } + } + + async newSession(params: NewSessionRequest): Promise { + try { + validateNewSessionRequest(params); + await this.harness.ensureConfigFile(); + await requireAcpAuthReady(this.harness); + const session = await this.harness.createSession({ + workDir: params.cwd, + permission: 'manual', + metadata: { acp: true }, + mcpServers: acpMcpServersToKimiConfig(params.mcpServers), + }); + const acpSession = new KimiAcpSession(session, this.options.connection); + this.sessions.set(acpSession.id, acpSession); + const configuration = await this.sessionConfiguration(acpSession); + return { + sessionId: acpSession.id, + ...configuration, + }; + } catch (error) { + throw toAcpRequestError(error); + } + } + + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + try { + await this.getSession(params.sessionId).setModel(params.modelId); + return {}; + } catch (error) { + throw toAcpSetModelRequestError(error); + } + } + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + try { + if (params.configId !== MODEL_CONFIG_OPTION_ID) { + throw RequestError.invalidParams( + { configId: params.configId }, + `Unsupported session config option "${params.configId}".`, + ); + } + if (typeof params.value !== 'string') { + throw RequestError.invalidParams( + { configId: params.configId, value: params.value }, + 'Model config option value must be a string.', + ); + } + + const session = this.getSession(params.sessionId); + await session.setModel(params.value); + return { + configOptions: await this.configOptions(session), + }; + } catch (error) { + throw toAcpSetModelRequestError(error); + } + } + + async resumeSession(params: ResumeSessionRequest): Promise { + try { + validateResumeSessionRequest(params); + await this.harness.ensureConfigFile(); + await requireAcpAuthReady(this.harness); + const summary = await this.findSessionSummary(params.sessionId); + if (resolve(summary.workDir) !== resolve(params.cwd)) { + throw RequestError.invalidParams( + { cwd: params.cwd, sessionCwd: summary.workDir }, + 'cwd must match the persisted session work directory', + ); + } + + const existing = this.sessions.get(params.sessionId); + if (existing !== undefined) { + return await this.resumeResponse(existing); + } + + const session = await this.harness.resumeSession({ + id: params.sessionId, + mcpServers: acpMcpServersToKimiConfig(params.mcpServers ?? []), + }); + const acpSession = new KimiAcpSession(session, this.options.connection); + this.sessions.set(acpSession.id, acpSession); + return await this.resumeResponse(acpSession); + } catch (error) { + throw toAcpRequestError(error); + } + } + + async unstable_forkSession(params: ForkSessionRequest): Promise { + try { + validateForkSessionRequest(params); + await this.harness.ensureConfigFile(); + await requireAcpAuthReady(this.harness); + const sourceSummary = await this.findSessionSummary(params.sessionId); + if (resolve(sourceSummary.workDir) !== resolve(params.cwd)) { + throw RequestError.invalidParams( + { cwd: params.cwd, sessionCwd: sourceSummary.workDir }, + 'cwd must match the source session work directory', + ); + } + + const session = await this.harness.forkSession({ + id: params.sessionId, + mcpServers: acpMcpServersToKimiConfig(params.mcpServers ?? []), + }); + const acpSession = new KimiAcpSession(session, this.options.connection); + this.sessions.set(acpSession.id, acpSession); + const configuration = await this.sessionConfiguration(acpSession); + return { + sessionId: acpSession.id, + ...configuration, + }; + } catch (error) { + throw toAcpRequestError(error); + } + } + + async prompt(params: PromptRequest): Promise { + return this.getSession(params.sessionId).prompt(params); + } + + async cancel(params: CancelNotification): Promise { + await this.getSession(params.sessionId).cancel(); + } + + async closeSession(params: CloseSessionRequest): Promise { + const session = this.getSession(params.sessionId); + await session.close(); + this.sessions.delete(params.sessionId); + return {}; + } + + async close(): Promise { + const sessions = Array.from(this.sessions.values()); + this.sessions.clear(); + await Promise.all( + sessions.map((session) => + session.close().catch((error: unknown) => { + log.warn('acp session close failed', { sessionId: session.id, error }); + }), + ), + ); + await this.harness.close(); + } + + private getSession(sessionId: string): KimiAcpSession { + const session = this.sessions.get(sessionId); + if (session === undefined) { + throw RequestError.resourceNotFound(`session:${sessionId}`); + } + return session; + } + + private async modelState(session: KimiAcpSession): Promise { + const config = await this.harness.getConfig({ reload: true }); + return session.modelState(config); + } + + private async configOptions(session: KimiAcpSession): Promise { + return createAcpModelConfigOptions(await this.modelState(session)); + } + + private async sessionConfiguration(session: KimiAcpSession): Promise<{ + readonly models: SessionModelState | undefined; + readonly configOptions: SessionConfigOption[]; + }> { + const models = await this.modelState(session); + return { + models, + configOptions: createAcpModelConfigOptions(models), + }; + } + + private async resumeResponse(session: KimiAcpSession): Promise { + return this.sessionConfiguration(session); + } + + private async findSessionSummary(sessionId: string): Promise { + const sessions = await this.harness.listSessions({ sessionId }); + const summary = sessions[0]; + if (summary === undefined) { + throw RequestError.resourceNotFound(`session:${sessionId}`); + } + return summary; + } +} + +function createAgentCapabilities(): KimiAgentCapabilities { + return { + promptCapabilities: { + image: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + }, + sessionCapabilities: { + close: {}, + configOptions: {}, + fork: {}, + list: {}, + resume: {}, + }, + }; +} + +function validateListSessionsRequest(params: ListSessionsRequest): void { + if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) { + throw RequestError.invalidParams({ cwd: params.cwd }, 'cwd must be absolute'); + } + + if (params.cursor !== undefined && params.cursor !== null) { + throw RequestError.invalidParams( + { cursor: params.cursor }, + 'session/list cursor pagination is not supported', + ); + } +} + +function validateNewSessionRequest(params: NewSessionRequest): void { + validateSessionWorkDir(params.cwd); + validateAdditionalDirectories(params.additionalDirectories); +} + +function validateResumeSessionRequest(params: ResumeSessionRequest): void { + validateSessionWorkDir(params.cwd); + validateAdditionalDirectories(params.additionalDirectories); +} + +function validateForkSessionRequest(params: ForkSessionRequest): void { + validateSessionWorkDir(params.cwd); + validateAdditionalDirectories(params.additionalDirectories); +} + +function validateSessionWorkDir(cwd: string): void { + if (!isAbsolute(cwd)) { + throw RequestError.invalidParams({ cwd }, 'cwd must be absolute'); + } +} + +function validateAdditionalDirectories( + additionalDirectories: readonly string[] | undefined, +): void { + if ((additionalDirectories?.length ?? 0) > 0) { + throw RequestError.invalidParams( + { additionalDirectories }, + 'additionalDirectories are not supported', + ); + } +} + +function acpSessionInfoFromSummary(summary: SessionSummary): SessionInfo { + return { + sessionId: summary.id, + cwd: summary.workDir, + title: summary.title, + updatedAt: new Date(summary.updatedAt).toISOString(), + }; +} diff --git a/apps/kimi-code/src/acp/auth-adapter.ts b/apps/kimi-code/src/acp/auth-adapter.ts new file mode 100644 index 00000000..7d995157 --- /dev/null +++ b/apps/kimi-code/src/acp/auth-adapter.ts @@ -0,0 +1,118 @@ +import { RequestError, type AuthMethod, type ClientCapabilities } from '@agentclientprotocol/sdk'; +import { + ErrorCodes, + type KimiHarness, +} from '@moonshot-ai/kimi-code-sdk'; + +const AUTH_METHOD_EXISTING_CONFIG = 'kimi-code-existing-config'; +const AUTH_METHOD_ENV_MODEL = 'kimi-model-env'; +const AUTH_METHOD_TERMINAL = 'kimi-code-terminal'; + +export function createAuthMethods(clientCapabilities?: ClientCapabilities): AuthMethod[] { + const methods: AuthMethod[] = [ + { + id: AUTH_METHOD_EXISTING_CONFIG, + name: 'Kimi Code config', + description: 'Use an existing Kimi Code config.toml, API key, or OAuth login.', + }, + { + type: 'env_var', + id: AUTH_METHOD_ENV_MODEL, + name: 'KIMI_MODEL environment', + description: 'Configure the ACP server with KIMI_MODEL_* environment variables.', + vars: [ + { name: 'KIMI_MODEL_NAME', label: 'Model name', secret: false }, + { name: 'KIMI_MODEL_API_KEY', label: 'API key', secret: true }, + { name: 'KIMI_MODEL_PROVIDER_TYPE', label: 'Provider type', optional: true, secret: false }, + { name: 'KIMI_MODEL_BASE_URL', label: 'Base URL', optional: true, secret: false }, + ], + }, + ]; + + if (supportsTerminalAuth(clientCapabilities)) { + methods.push({ + type: 'terminal', + id: AUTH_METHOD_TERMINAL, + name: 'Kimi Code terminal login', + description: 'Open Kimi Code in a terminal and use /login, then retry session creation.', + args: [], + }); + } + + return methods; +} + +function supportsTerminalAuth(clientCapabilities?: ClientCapabilities): boolean { + if (clientCapabilities?.auth?.terminal === true || clientCapabilities?.terminal === true) { + return true; + } + return clientCapabilities?._meta?.['terminal-auth'] === true; +} + +export async function authenticateAcpMethod( + harness: KimiHarness, + methodId: string, +): Promise { + if ( + methodId !== AUTH_METHOD_EXISTING_CONFIG && + methodId !== AUTH_METHOD_ENV_MODEL && + methodId !== AUTH_METHOD_TERMINAL + ) { + throw RequestError.invalidParams({ methodId }, 'unknown authentication method'); + } + + await requireAcpAuthReady(harness); +} + +export async function requireAcpAuthReady(harness: KimiHarness): Promise { + const config = await harness.getConfig({ reload: true }); + const modelName = config.defaultModel?.trim(); + if (modelName === undefined || modelName.length === 0) { + throw authRequired(harness, 'No default model is configured.'); + } + + const model = config.models?.[modelName]; + if (model === undefined) { + throw authRequired(harness, `Default model "${modelName}" is not configured.`); + } + + const providerName = model.provider ?? config.defaultProvider; + if (providerName === undefined) { + throw authRequired(harness, `Model "${modelName}" does not specify a provider.`); + } + + const provider = config.providers[providerName]; + if (provider === undefined) { + throw authRequired(harness, `Provider "${providerName}" is not configured.`); + } + + if (provider.oauth !== undefined && !(await hasOAuthToken(harness, providerName))) { + throw authRequired(harness, `Provider "${providerName}" requires login.`); + } +} + +export function isAuthConfigurationError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === ErrorCodes.AUTH_LOGIN_REQUIRED || + error.code === ErrorCodes.MODEL_NOT_CONFIGURED || + error.code === ErrorCodes.PROVIDER_AUTH_ERROR) + ); +} + +function authRequired(harness: KimiHarness, reason: string): RequestError { + return RequestError.authRequired( + { + reason, + configPath: harness.configPath, + }, + reason, + ); +} + +async function hasOAuthToken(harness: KimiHarness, providerName: string): Promise { + const status = await harness.auth.status(providerName); + return status.providers.some((provider) => provider.providerName === providerName && provider.hasToken); +} diff --git a/apps/kimi-code/src/acp/content-adapter.ts b/apps/kimi-code/src/acp/content-adapter.ts new file mode 100644 index 00000000..ca116775 --- /dev/null +++ b/apps/kimi-code/src/acp/content-adapter.ts @@ -0,0 +1,70 @@ +import { + RequestError, + type ContentBlock, + type EmbeddedResourceResource, +} from '@agentclientprotocol/sdk'; +import type { PromptInput, PromptPart } from '@moonshot-ai/kimi-code-sdk'; + +export function acpPromptToKimiInput(prompt: readonly ContentBlock[]): PromptInput { + return prompt.flatMap((block) => acpContentBlockToKimiParts(block)); +} + +function acpContentBlockToKimiParts(block: ContentBlock): PromptPart[] { + switch (block.type) { + case 'text': + return [{ type: 'text', text: block.text }]; + case 'image': + return [ + { + type: 'image_url', + imageUrl: { + url: block.uri ?? `data:${block.mimeType};base64,${block.data}`, + }, + }, + ]; + case 'resource_link': + return [{ type: 'text', text: formatResourceLink(block) }]; + case 'resource': + return embeddedResourceToPromptParts(block.resource); + case 'audio': + throw RequestError.invalidParams( + { contentType: 'audio' }, + 'audio prompt blocks are not supported', + ); + default: { + const exhaustive: never = block; + void exhaustive; + throw RequestError.invalidParams(undefined, 'unsupported prompt block'); + } + } +} + +function embeddedResourceToPromptParts(resource: EmbeddedResourceResource): PromptPart[] { + if ('text' in resource) { + return [{ type: 'text', text: resource.text }]; + } + + const mimeType = resource.mimeType ?? 'application/octet-stream'; + if (mimeType.startsWith('image/')) { + return [ + { + type: 'image_url', + imageUrl: { url: `data:${mimeType};base64,${resource.blob}` }, + }, + ]; + } + + return [ + { + type: 'text', + text: `Embedded binary resource: ${resource.uri}${resource.mimeType ? ` (${resource.mimeType})` : ''}`, + }, + ]; +} + +function formatResourceLink(block: Extract): string { + const lines = [block.title ?? block.name, block.description, block.uri].filter( + (line): line is string => typeof line === 'string' && line.length > 0, + ); + return lines.join('\n'); +} diff --git a/apps/kimi-code/src/acp/errors.ts b/apps/kimi-code/src/acp/errors.ts new file mode 100644 index 00000000..b4108f03 --- /dev/null +++ b/apps/kimi-code/src/acp/errors.ts @@ -0,0 +1,46 @@ +import { RequestError } from '@agentclientprotocol/sdk'; +import { ErrorCodes, isKimiError, toKimiErrorPayload } from '@moonshot-ai/kimi-code-sdk'; + +import { isAuthConfigurationError } from './auth-adapter'; + +export function toAcpRequestError(error: unknown): RequestError { + if (error instanceof RequestError) return error; + if (isAuthConfigurationError(error)) { + return RequestError.authRequired(errorData(error), errorMessage(error)); + } + if (isKimiError(error) && error.code === ErrorCodes.SESSION_NOT_FOUND) { + return RequestError.resourceNotFound(error.message); + } + return RequestError.internalError(errorData(error), errorMessage(error)); +} + +export function toAcpSetModelRequestError(error: unknown): RequestError { + if (error instanceof RequestError) return error; + if (isKimiError(error) && SET_MODEL_INVALID_PARAM_CODES.has(error.code)) { + return RequestError.invalidParams(errorData(error), errorMessage(error)); + } + return toAcpRequestError(error); +} + +const SET_MODEL_INVALID_PARAM_CODES = new Set([ + ErrorCodes.CONFIG_INVALID, + ErrorCodes.MODEL_CONFIG_INVALID, + ErrorCodes.MODEL_NOT_CONFIGURED, + ErrorCodes.SESSION_MODEL_EMPTY, +]); + +function errorData(error: unknown): unknown { + if (isKimiError(error)) return toKimiErrorPayload(error); + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + return { message: String(error) }; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/apps/kimi-code/src/acp/index.ts b/apps/kimi-code/src/acp/index.ts new file mode 100644 index 00000000..79f1a698 --- /dev/null +++ b/apps/kimi-code/src/acp/index.ts @@ -0,0 +1,28 @@ +import { Readable, Writable } from 'node:stream'; + +import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'; + +import { KimiAcpAgent } from './agent'; + +export interface RunAcpServerOptions { + readonly version: string; +} + +export async function runAcpServer(options: RunAcpServerOptions): Promise { + let agent: KimiAcpAgent | undefined; + const input = Readable.toWeb(process.stdin) as ReadableStream; + const output = Writable.toWeb(process.stdout) as WritableStream; + const connection = new AgentSideConnection((conn) => { + agent = new KimiAcpAgent({ + connection: conn, + version: options.version, + }); + return agent; + }, ndJsonStream(output, input)); + + try { + await connection.closed; + } finally { + await agent?.close(); + } +} diff --git a/apps/kimi-code/src/acp/mcp-adapter.ts b/apps/kimi-code/src/acp/mcp-adapter.ts new file mode 100644 index 00000000..cb7f0ca4 --- /dev/null +++ b/apps/kimi-code/src/acp/mcp-adapter.ts @@ -0,0 +1,78 @@ +import { RequestError, type HttpHeader, type McpServer } from '@agentclientprotocol/sdk'; +import type { McpServerConfig } from '@moonshot-ai/kimi-code-sdk'; + +export function acpMcpServersToKimiConfig( + servers: readonly McpServer[], +): Record | undefined { + if (servers.length === 0) return undefined; + + const result: Record = {}; + for (const server of servers) { + if (Object.hasOwn(result, server.name)) { + throw RequestError.invalidParams( + { serverName: server.name }, + 'duplicate MCP server name', + ); + } + result[server.name] = acpMcpServerToKimiConfig(server); + } + return result; +} + +function acpMcpServerToKimiConfig(server: McpServer): McpServerConfig { + if ('command' in server) { + const env = envVariablesToRecord(server.env); + return { + transport: 'stdio', + command: server.command, + args: server.args.length > 0 ? server.args : undefined, + env, + }; + } + + switch (server.type) { + case 'http': { + const headers = headersToRecord(server.headers); + return { + transport: 'http', + url: server.url, + headers, + }; + } + case 'sse': + throw RequestError.invalidParams( + { serverName: server.name, transport: 'sse' }, + 'SSE MCP servers are not supported', + ); + case 'acp': + throw RequestError.invalidParams( + { serverName: server.name, transport: 'acp' }, + 'ACP-transport MCP servers are not supported', + ); + default: { + const exhaustive: never = server; + void exhaustive; + throw RequestError.invalidParams(undefined, 'unsupported MCP server transport'); + } + } +} + +function headersToRecord(headers: readonly HttpHeader[]): Record | undefined { + if (headers.length === 0) return undefined; + const result: Record = {}; + for (const header of headers) { + result[header.name] = header.value; + } + return result; +} + +function envVariablesToRecord( + env: readonly { readonly name: string; readonly value: string }[], +): Record | undefined { + if (env.length === 0) return undefined; + const result: Record = {}; + for (const item of env) { + result[item.name] = item.value; + } + return result; +} diff --git a/apps/kimi-code/src/acp/model-adapter.ts b/apps/kimi-code/src/acp/model-adapter.ts new file mode 100644 index 00000000..7e220972 --- /dev/null +++ b/apps/kimi-code/src/acp/model-adapter.ts @@ -0,0 +1,99 @@ +import type { + ModelInfo, + SessionConfigOption, + SessionConfigSelectOption, + SessionModelState, +} from '@agentclientprotocol/sdk'; +import { + log, + type KimiConfig, + type Session, + type SessionStatus, +} from '@moonshot-ai/kimi-code-sdk'; + +export const MODEL_CONFIG_OPTION_ID = 'model'; + +export async function createAcpModelState( + config: KimiConfig, + session: Session, +): Promise { + const availableModels = availableModelsFromConfig(config); + const status = await session.getStatus().catch((error: unknown): SessionStatus | undefined => { + log.warn('acp model status read failed', { sessionId: session.id, error }); + return undefined; + }); + const currentModelId = firstNonEmpty([ + status?.model, + config.defaultModel, + availableModels[0]?.modelId, + ]); + + if (currentModelId === undefined) return undefined; + const models = availableModels.some((model) => model.modelId === currentModelId) + ? availableModels + : [ + ...availableModels, + { + modelId: currentModelId, + name: currentModelId, + description: 'Current session model', + }, + ]; + + return { + availableModels: models, + currentModelId, + }; +} + +export function createAcpModelConfigOptions( + modelState: SessionModelState | undefined, +): SessionConfigOption[] { + if (modelState === undefined) return []; + + return [ + { + id: MODEL_CONFIG_OPTION_ID, + name: 'Model', + category: 'model', + type: 'select', + currentValue: modelState.currentModelId, + options: modelState.availableModels.map(modelConfigOptionFromInfo), + }, + ]; +} + +function availableModelsFromConfig(config: KimiConfig): ModelInfo[] { + return Object.entries(config.models ?? {}).map(([modelId, alias]) => ({ + modelId, + name: firstNonEmpty([alias.displayName, modelId]) ?? modelId, + description: modelDescription(alias.provider, alias.model, alias.maxContextSize), + })); +} + +function modelDescription( + provider: string, + model: string, + maxContextSize: number, +): string { + return `${provider}/${model} (${String(maxContextSize)} context)`; +} + +function modelConfigOptionFromInfo(model: ModelInfo): SessionConfigSelectOption { + const option: SessionConfigSelectOption = { + value: model.modelId, + name: model.name, + }; + if (model.description !== undefined && model.description !== null) { + option.description = model.description; + } + return option; +} + +function firstNonEmpty(values: readonly (string | undefined)[]): string | undefined { + for (const value of values) { + const normalized = value?.trim(); + if (normalized !== undefined && normalized.length > 0) return normalized; + } + return undefined; +} diff --git a/apps/kimi-code/src/acp/session.ts b/apps/kimi-code/src/acp/session.ts new file mode 100644 index 00000000..3c56a244 --- /dev/null +++ b/apps/kimi-code/src/acp/session.ts @@ -0,0 +1,435 @@ +import { + RequestError, + type AgentSideConnection, + type ContentBlock, + type PromptRequest, + type PromptResponse, + type SessionModelState, + type SessionUpdate, + type StopReason, + type ToolCallContent, +} from '@agentclientprotocol/sdk'; +import { + log, + type ApprovalRequest, + type Event, + type KimiConfig, + type Session, + type TurnEndReason, +} from '@moonshot-ai/kimi-code-sdk'; + +import { acpPromptToKimiInput } from './content-adapter'; +import { toAcpRequestError } from './errors'; +import { createAcpModelState } from './model-adapter'; +import { + approvalRequestToToolCallUpdate, + approvalResponseFromOutcome, + defaultPermissionOptions, + displayToContent, + displayToLocations, + displayToToolKind, + stringifyForDisplay, + textToolContent, + toAcpToolCallId, + toolUpdateToText, +} from './tool-adapter'; + +interface TurnEndState { + readonly reason: TurnEndReason; + readonly error?: unknown; +} + +interface PendingPermission { + readonly cancel: () => void; +} + +interface PendingTurn { + readonly resolve: (state: TurnEndState) => void; +} + +export class KimiAcpSession { + private readonly unsubscribe: () => void; + private eventQueue: Promise = Promise.resolve(); + private activeTurnId: number | undefined; + private lastTurnEnd: TurnEndState | undefined; + private runningPrompt = false; + private closed = false; + private readonly toolContents = new Map(); + private readonly toolInputFragments = new Map(); + private readonly pendingPermissions = new Set(); + private pendingTurn: PendingTurn | undefined; + + constructor( + private readonly session: Session, + private readonly connection: AgentSideConnection, + ) { + this.unsubscribe = session.onEvent((event) => { + this.handleEvent(event); + }); + session.setApprovalHandler((request) => this.requestPermission(request)); + } + + get id(): string { + return this.session.id; + } + + async modelState(config: KimiConfig): Promise { + this.ensureOpen(); + return createAcpModelState(config, this.session); + } + + async setModel(modelId: string): Promise { + this.ensureOpen(); + await this.session.setModel(modelId); + } + + async prompt(request: PromptRequest): Promise { + this.ensureOpen(); + if (this.runningPrompt) { + throw RequestError.invalidParams( + { sessionId: this.id }, + `Session "${this.id}" is already processing a prompt.`, + ); + } + + const input = acpPromptToKimiInput(request.prompt); + this.runningPrompt = true; + this.activeTurnId = undefined; + this.lastTurnEnd = undefined; + this.toolContents.clear(); + this.toolInputFragments.clear(); + + try { + const turnEnded = this.waitForPromptTurnEnd(); + this.sendUserPromptChunks(request.prompt, request.messageId); + await this.session.prompt(input); + const turnEnd = await turnEnded; + this.lastTurnEnd = turnEnd; + await this.flushEvents(); + return promptResponseFromTurnEnd(this.lastTurnEnd, request.messageId); + } catch (error) { + throw toAcpRequestError(error); + } finally { + this.runningPrompt = false; + } + } + + async cancel(): Promise { + this.ensureOpen(); + this.cancelPendingPermissions(); + await this.session.cancel(); + this.resolvePromptTurn({ reason: 'cancelled' }); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.session.setApprovalHandler(undefined); + this.unsubscribe(); + this.cancelPendingPermissions(); + if (this.runningPrompt) { + await this.session.cancel().catch((error: unknown) => { + log.warn('acp session cancel during close failed', { sessionId: this.id, error }); + }); + this.resolvePromptTurn({ reason: 'cancelled' }); + } + await this.session.close(); + } + + private ensureOpen(): void { + if (this.closed) { + throw toAcpRequestError(new Error(`Session "${this.id}" is closed.`)); + } + } + + private async requestPermission(request: ApprovalRequest) { + const options = defaultPermissionOptions(); + const pending = this.trackPendingPermission(); + try { + const response = await Promise.race([ + this.connection.requestPermission({ + sessionId: this.id, + toolCall: approvalRequestToToolCallUpdate(request), + options: [...options], + }), + pending.cancelled, + ]); + const approval = approvalResponseFromOutcome(response.outcome, options); + if (approval.decision !== 'approved') { + this.sendPermissionDeniedUpdate(request, approval.decision); + } + return approval; + } catch (error) { + log.warn('acp permission request failed', { sessionId: this.id, error }); + this.sendPermissionDeniedUpdate(request, 'cancelled'); + return { + decision: 'cancelled' as const, + feedback: 'ACP client did not return a permission decision.', + }; + } finally { + this.pendingPermissions.delete(pending.record); + } + } + + private trackPendingPermission(): { + readonly record: PendingPermission; + readonly cancelled: Promise<{ outcome: { outcome: 'cancelled' } }>; + } { + let cancel!: () => void; + const cancelled = new Promise<{ outcome: { outcome: 'cancelled' } }>((resolve) => { + cancel = () => { + resolve({ outcome: { outcome: 'cancelled' } }); + }; + }); + const record: PendingPermission = { cancel }; + this.pendingPermissions.add(record); + return { record, cancelled }; + } + + private cancelPendingPermissions(): void { + for (const pending of Array.from(this.pendingPermissions)) { + pending.cancel(); + this.pendingPermissions.delete(pending); + } + } + + private sendPermissionDeniedUpdate( + request: ApprovalRequest, + decision: 'rejected' | 'cancelled', + ): void { + const toolCallId = toAcpToolCallId(request.turnId, request.toolCallId); + const content = this.appendToolContent( + toolCallId, + decision === 'rejected' ? 'Permission rejected.' : 'Permission cancelled.', + ); + this.send({ + sessionUpdate: 'tool_call_update', + toolCallId, + title: request.action, + status: 'failed', + content, + }); + } + + private handleEvent(event: Event): void { + switch (event.type) { + case 'turn.started': + this.activeTurnId = event.turnId; + return; + case 'turn.ended': + this.resolvePromptTurn({ reason: event.reason, error: event.error }); + return; + case 'assistant.delta': + if (this.isActiveTurn(event.turnId)) { + this.sendTextChunk('agent_message_chunk', event.delta); + } + return; + case 'thinking.delta': + if (this.isActiveTurn(event.turnId)) { + this.sendTextChunk('agent_thought_chunk', event.delta); + } + return; + case 'hook.result': + if (this.isActiveTurn(event.turnId)) { + this.sendTextChunk('agent_thought_chunk', event.content); + } + return; + case 'tool.call.started': + this.handleToolCallStarted(event); + return; + case 'tool.call.delta': + this.handleToolCallDelta(event); + return; + case 'tool.progress': + this.handleToolProgress(event); + return; + case 'tool.result': + this.handleToolResult(event); + return; + case 'mcp.server.status': + this.sendTextChunk( + 'agent_thought_chunk', + `MCP ${event.server.name}: ${event.server.status}${event.server.error ? `\n${event.server.error}` : ''}`, + ); + return; + case 'warning': + this.sendTextChunk('agent_thought_chunk', `Warning: ${event.message}`); + return; + case 'error': + this.sendTextChunk('agent_message_chunk', event.message); + return; + case 'agent.status.updated': + case 'session.meta.updated': + case 'skill.activated': + case 'turn.step.started': + case 'turn.step.completed': + case 'turn.step.retrying': + case 'turn.step.interrupted': + case 'tool.list.updated': + case 'subagent.spawned': + case 'subagent.completed': + case 'subagent.failed': + case 'compaction.started': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'background.task.started': + case 'background.task.updated': + case 'background.task.terminated': + return; + default: { + const exhaustive: never = event; + void exhaustive; + return; + } + } + } + + private handleToolCallStarted(event: Extract): void { + if (!this.isActiveTurn(event.turnId)) return; + const toolCallId = toAcpToolCallId(event.turnId, event.toolCallId); + const content = displayToContent(event.display); + if (content !== undefined) { + this.toolContents.set(toolCallId, content); + } + + this.send({ + sessionUpdate: 'tool_call', + toolCallId, + title: event.description ?? event.name, + kind: displayToToolKind(event.display, event.name), + status: 'in_progress', + rawInput: event.args, + locations: displayToLocations(event.display), + content, + }); + } + + private handleToolCallDelta(event: Extract): void { + if (!this.isActiveTurn(event.turnId)) return; + const toolCallId = toAcpToolCallId(event.turnId, event.toolCallId); + const previous = this.toolInputFragments.get(toolCallId) ?? ''; + const rawInput = + event.argumentsPart === undefined ? previous : `${previous}${event.argumentsPart}`; + if (rawInput.length > 0) { + this.toolInputFragments.set(toolCallId, rawInput); + } + + this.send({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'in_progress', + title: event.name, + rawInput: rawInput.length === 0 ? undefined : rawInput, + }); + } + + private handleToolProgress(event: Extract): void { + if (!this.isActiveTurn(event.turnId)) return; + const toolCallId = toAcpToolCallId(event.turnId, event.toolCallId); + const text = toolUpdateToText(event.update); + const content = this.appendToolContent(toolCallId, text); + this.send({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'in_progress', + content: content.length === 0 ? undefined : content, + }); + } + + private handleToolResult(event: Extract): void { + if (!this.isActiveTurn(event.turnId)) return; + const toolCallId = toAcpToolCallId(event.turnId, event.toolCallId); + const output = stringifyForDisplay(event.output); + const content = output.length === 0 + ? this.toolContents.get(toolCallId) ?? [] + : this.appendToolContent(toolCallId, output); + this.send({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: event.isError === true ? 'failed' : 'completed', + rawOutput: event.output, + content: content.length === 0 ? undefined : content, + }); + } + + private appendToolContent(toolCallId: string, text: string): ToolCallContent[] { + if (text.length === 0) return this.toolContents.get(toolCallId) ?? []; + const content = [...(this.toolContents.get(toolCallId) ?? []), textToolContent(text)]; + this.toolContents.set(toolCallId, content); + return content; + } + + private isActiveTurn(turnId: number): boolean { + return this.activeTurnId === undefined || this.activeTurnId === turnId; + } + + private sendUserPromptChunks( + prompt: readonly ContentBlock[], + messageId: string | null | undefined, + ): void { + for (const block of prompt) { + this.send({ + sessionUpdate: 'user_message_chunk', + content: block, + messageId: messageId ?? undefined, + }); + } + } + + private sendTextChunk( + sessionUpdate: 'agent_message_chunk' | 'agent_thought_chunk', + text: string, + ): void { + if (text.length === 0) return; + this.send({ + sessionUpdate, + content: { + type: 'text', + text, + }, + }); + } + + private send(update: SessionUpdate): void { + this.eventQueue = this.eventQueue + .catch(() => {}) + .then(() => this.connection.sessionUpdate({ sessionId: this.id, update })) + .catch((error: unknown) => { + log.warn('acp session update failed', { sessionId: this.id, error }); + }); + } + + private async flushEvents(): Promise { + await this.eventQueue.catch(() => {}); + } + + private waitForPromptTurnEnd(): Promise { + return new Promise((resolve) => { + this.pendingTurn = { resolve }; + }); + } + + private resolvePromptTurn(state: TurnEndState): void { + this.lastTurnEnd = state; + const pending = this.pendingTurn; + if (pending === undefined) return; + this.pendingTurn = undefined; + pending.resolve(state); + } +} + +function promptResponseFromTurnEnd( + turnEnd: TurnEndState | undefined, + messageId: string | null | undefined, +): PromptResponse { + if (turnEnd?.reason === 'failed') { + throw toAcpRequestError(turnEnd.error ?? new Error('Prompt turn failed.')); + } + + const stopReason: StopReason = turnEnd?.reason === 'cancelled' ? 'cancelled' : 'end_turn'; + return { + stopReason, + userMessageId: messageId ?? undefined, + }; +} diff --git a/apps/kimi-code/src/acp/tool-adapter.ts b/apps/kimi-code/src/acp/tool-adapter.ts new file mode 100644 index 00000000..b45c432f --- /dev/null +++ b/apps/kimi-code/src/acp/tool-adapter.ts @@ -0,0 +1,268 @@ +import type { + PermissionOption, + RequestPermissionOutcome, + ToolCallContent, + ToolCallLocation, + ToolKind, + ToolCallUpdate, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalRequest, + ApprovalResponse, + ToolInputDisplay, + ToolUpdate, +} from '@moonshot-ai/kimi-code-sdk'; + +const PERMISSION_OPTIONS: readonly PermissionOption[] = [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow for session', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, +]; + +export function defaultPermissionOptions(): readonly PermissionOption[] { + return PERMISSION_OPTIONS; +} + +export function toAcpToolCallId(turnId: number | undefined, toolCallId: string): string { + return turnId === undefined ? toolCallId : `${turnId}:${toolCallId}`; +} + +export function approvalRequestToToolCallUpdate(request: ApprovalRequest): ToolCallUpdate { + return { + toolCallId: toAcpToolCallId(request.turnId, request.toolCallId), + title: request.action, + kind: displayToToolKind(request.display, request.toolName), + locations: displayToLocations(request.display), + content: displayToContent(request.display), + }; +} + +export function approvalResponseFromOutcome( + outcome: RequestPermissionOutcome, + options: readonly PermissionOption[] = PERMISSION_OPTIONS, +): ApprovalResponse { + if (outcome.outcome === 'cancelled') { + return { decision: 'cancelled' }; + } + + const option = options.find((item) => item.optionId === outcome.optionId); + switch (option?.kind) { + case 'allow_once': + return { decision: 'approved', selectedLabel: option.name }; + case 'allow_always': + return { decision: 'approved', scope: 'session', selectedLabel: option.name }; + case 'reject_once': + case 'reject_always': + return { decision: 'rejected', selectedLabel: option.name }; + case undefined: + return { decision: 'cancelled', feedback: `Unknown permission option: ${outcome.optionId}` }; + default: + return { decision: 'cancelled' }; + } +} + +export function displayToToolKind( + display: ToolInputDisplay | undefined, + fallbackName?: string, +): ToolKind { + if (display === undefined) return nameToToolKind(fallbackName); + switch (display.kind) { + case 'command': + case 'background_task': + case 'task_stop': + return 'execute'; + case 'file_io': + return fileOperationToToolKind(display.operation); + case 'diff': + return 'edit'; + case 'search': + return 'search'; + case 'url_fetch': + return 'fetch'; + case 'todo_list': + case 'plan_review': + return 'think'; + case 'agent_call': + case 'skill_call': + case 'generic': + return nameToToolKind(fallbackName); + default: { + const exhaustive: never = display; + void exhaustive; + return 'other'; + } + } +} + +export function displayToLocations( + display: ToolInputDisplay | undefined, +): ToolCallLocation[] | undefined { + if (display === undefined) return undefined; + switch (display.kind) { + case 'file_io': + case 'diff': + return [{ path: display.path }]; + case 'plan_review': + return display.path === undefined ? undefined : [{ path: display.path }]; + case 'agent_call': + case 'background_task': + case 'command': + case 'generic': + case 'search': + case 'skill_call': + case 'task_stop': + case 'todo_list': + case 'url_fetch': + return undefined; + default: { + const exhaustive: never = display; + void exhaustive; + return undefined; + } + } +} + +export function displayToContent( + display: ToolInputDisplay | undefined, +): ToolCallContent[] | undefined { + if (display === undefined) return undefined; + switch (display.kind) { + case 'command': + return [textToolContent(formatCommandDisplay(display))]; + case 'file_io': + return fileDisplayToContent(display); + case 'diff': + return [ + { + type: 'diff', + path: display.path, + oldText: display.before, + newText: display.after, + }, + ]; + case 'search': + return [textToolContent(`Search: ${display.query}${display.scope ? `\nScope: ${display.scope}` : ''}`)]; + case 'url_fetch': + return [textToolContent(`${display.method ?? 'GET'} ${display.url}`)]; + case 'agent_call': + return [textToolContent(`${display.agent_name}${display.background === true ? ' (background)' : ''}\n${display.prompt}`)]; + case 'skill_call': + return [textToolContent(`${display.skill_name}${display.args ? `\n${display.args}` : ''}`)]; + case 'todo_list': + return [ + textToolContent( + display.items.map((item) => `${item.status}: ${item.title}`).join('\n'), + ), + ]; + case 'background_task': + return [textToolContent(`${display.status}: ${display.description}`)]; + case 'task_stop': + return [textToolContent(`Stop task ${display.task_id}: ${display.task_description}`)]; + case 'plan_review': + return [textToolContent(display.plan)]; + case 'generic': + return [ + textToolContent( + display.detail === undefined + ? display.summary + : `${display.summary}\n${stringifyForDisplay(display.detail)}`, + ), + ]; + default: { + const exhaustive: never = display; + void exhaustive; + return undefined; + } + } +} + +export function textToolContent(text: string): ToolCallContent { + return { + type: 'content', + content: { + type: 'text', + text, + }, + }; +} + +export function toolUpdateToText(update: ToolUpdate): string { + switch (update.kind) { + case 'stdout': + case 'stderr': + case 'progress': + case 'status': + return update.text ?? (update.percent === undefined ? update.kind : `${update.kind}: ${update.percent}%`); + case 'custom': + return update.text ?? stringifyForDisplay(update.customData ?? update.customKind ?? 'custom update'); + default: + return stringifyForDisplay(update); + } +} + +export function stringifyForDisplay(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function fileOperationToToolKind(operation: Extract['operation']): ToolKind { + switch (operation) { + case 'read': + return 'read'; + case 'write': + case 'edit': + return 'edit'; + case 'glob': + case 'grep': + return 'search'; + default: { + const exhaustive: never = operation; + void exhaustive; + return 'other'; + } + } +} + +function nameToToolKind(name: string | undefined): ToolKind { + const lower = name?.toLowerCase() ?? ''; + if (lower.includes('read')) return 'read'; + if (lower.includes('write') || lower.includes('edit') || lower.includes('patch')) return 'edit'; + if (lower.includes('delete') || lower.includes('remove')) return 'delete'; + if (lower.includes('search') || lower.includes('grep') || lower.includes('glob')) return 'search'; + if (lower.includes('fetch') || lower.includes('url')) return 'fetch'; + if (lower.includes('bash') || lower.includes('shell') || lower.includes('exec')) return 'execute'; + return 'other'; +} + +function formatCommandDisplay(display: Extract): string { + const lines = [display.cwd === undefined ? undefined : `cwd: ${display.cwd}`, display.command].filter( + (line): line is string => line !== undefined && line.length > 0, + ); + return lines.join('\n'); +} + +function fileDisplayToContent( + display: Extract, +): ToolCallContent[] | undefined { + if (display.before !== undefined || display.after !== undefined) { + return [ + { + type: 'diff', + path: display.path, + oldText: display.before ?? '', + newText: display.after ?? display.content ?? '', + }, + ]; + } + if (display.content !== undefined) { + return [textToolContent(display.content)]; + } + if (display.detail !== undefined) { + return [textToolContent(display.detail)]; + } + return undefined; +} diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2cdd4bd0..711d0389 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -10,12 +10,14 @@ import { registerExportCommand } from './sub/export'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; +export type AcpCommandHandler = () => void; export function createProgram( version: string, onMain: MainCommandHandler, onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, + onAcp: AcpCommandHandler = () => {}, ): Command { const program = new Command(CLI_COMMAND_NAME) .description('The Starting Point for Next-Gen Agents') @@ -76,6 +78,13 @@ export function createProgram( registerExportCommand(program); registerMigrateCommand(program, onMigrate); + program + .command('acp') + .description('Run as an ACP agent server over stdio.') + .action(() => { + onAcp(); + }); + program .command('__plugin_run_node', { hidden: true }) .argument('') diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index ffa13412..f31401dc 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -13,6 +13,7 @@ import { } from '@moonshot-ai/kimi-code-sdk'; import { installCrashHandlers, track } from '@moonshot-ai/kimi-telemetry'; +import { runAcpServer } from './acp/index'; import { createProgram } from './cli/commands'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; @@ -60,6 +61,11 @@ async function handleMigrateCommand(version: string): Promise { await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } +/** `kimi acp`: speak ACP JSON-RPC over stdio. */ +export async function handleAcpCommand(version: string): Promise { + await runAcpServer({ version }); +} + /** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */ const MIGRATE_CLI_OPTIONS: CLIOptions = { session: undefined, @@ -120,6 +126,14 @@ export function main(): void { process.exit(1); }); }, + () => { + void handleAcpCommand(version).catch(async (error: unknown) => { + await logStartupFailure('run ACP server', error); + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`); + process.exit(1); + }); + }, ); program.parse(process.argv); diff --git a/apps/kimi-code/test/acp/adapters.test.ts b/apps/kimi-code/test/acp/adapters.test.ts new file mode 100644 index 00000000..fdc0d814 --- /dev/null +++ b/apps/kimi-code/test/acp/adapters.test.ts @@ -0,0 +1,158 @@ +import { RequestError, type ContentBlock, type McpServer } from '@agentclientprotocol/sdk'; +import { describe, expect, it } from 'vitest'; + +import { acpPromptToKimiInput } from '#/acp/content-adapter'; +import { acpMcpServersToKimiConfig } from '#/acp/mcp-adapter'; +import { createAcpModelState } from '#/acp/model-adapter'; +import { + approvalRequestToToolCallUpdate, + approvalResponseFromOutcome, +} from '#/acp/tool-adapter'; + +describe('ACP adapter helpers', () => { + it('converts text, image, resource link, and embedded text prompt blocks', () => { + const prompt: ContentBlock[] = [ + { type: 'text', text: 'hello' }, + { type: 'image', data: 'AAAA', mimeType: 'image/png' }, + { + type: 'resource_link', + name: 'README.md', + title: 'README', + uri: 'file:///repo/README.md', + }, + { + type: 'resource', + resource: { + uri: 'file:///repo/context.txt', + text: 'embedded context', + mimeType: 'text/plain', + }, + }, + ]; + + expect(acpPromptToKimiInput(prompt)).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: 'README\nfile:///repo/README.md' }, + { type: 'text', text: 'embedded context' }, + ]); + }); + + it('rejects unsupported audio prompt blocks with invalid params', () => { + expect(() => + acpPromptToKimiInput([{ type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }]), + ).toThrow(RequestError); + }); + + it('converts stdio and http MCP servers to Kimi session MCP config', () => { + const servers: McpServer[] = [ + { + name: 'filesystem', + command: 'node', + args: ['server.mjs'], + env: [{ name: 'ROOT', value: '/tmp/project' }], + }, + { + type: 'http', + name: 'docs', + url: 'https://mcp.example.test', + headers: [{ name: 'X-Test', value: '1' }], + }, + ]; + + expect(acpMcpServersToKimiConfig(servers)).toEqual({ + filesystem: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + env: { ROOT: '/tmp/project' }, + }, + docs: { + transport: 'http', + url: 'https://mcp.example.test', + headers: { 'X-Test': '1' }, + }, + }); + }); + + it('rejects unsupported ACP MCP transports', () => { + expect(() => + acpMcpServersToKimiConfig([{ type: 'acp', name: 'client-tools', id: 'mcp-1' }]), + ).toThrow(RequestError); + }); + + it('maps ACP permission outcomes to SDK approval decisions', () => { + expect( + approvalResponseFromOutcome({ outcome: 'selected', optionId: 'allow_always' }), + ).toEqual({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Allow for session', + }); + expect(approvalResponseFromOutcome({ outcome: 'cancelled' })).toEqual({ + decision: 'cancelled', + }); + }); + + it('builds ACP model state from configured Kimi model aliases', async () => { + await expect( + createAcpModelState( + { + providers: {}, + defaultModel: 'deepseek/flash', + models: { + 'deepseek/flash': { + provider: 'deepseek', + model: 'deepseek-v4-flash', + maxContextSize: 128000, + displayName: 'DeepSeek Flash', + }, + }, + }, + { + getStatus: async () => ({ + model: 'deepseek/flash', + thinkingLevel: 'auto', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 128000, + contextUsage: 0, + }), + } as never, + ), + ).resolves.toEqual({ + availableModels: [ + { + modelId: 'deepseek/flash', + name: 'DeepSeek Flash', + description: 'deepseek/deepseek-v4-flash (128000 context)', + }, + ], + currentModelId: 'deepseek/flash', + }); + }); + + it('builds permission tool-call updates from display metadata', () => { + expect( + approvalRequestToToolCallUpdate({ + turnId: 9, + toolCallId: 'tc_1', + toolName: 'apply_patch', + action: 'Edit src/app.ts', + display: { + kind: 'diff', + path: '/repo/src/app.ts', + before: 'old', + after: 'new', + }, + }), + ).toEqual({ + toolCallId: '9:tc_1', + title: 'Edit src/app.ts', + kind: 'edit', + locations: [{ path: '/repo/src/app.ts' }], + content: [{ type: 'diff', path: '/repo/src/app.ts', oldText: 'old', newText: 'new' }], + }); + }); +}); diff --git a/apps/kimi-code/test/acp/agent.test.ts b/apps/kimi-code/test/acp/agent.test.ts new file mode 100644 index 00000000..4d766727 --- /dev/null +++ b/apps/kimi-code/test/acp/agent.test.ts @@ -0,0 +1,665 @@ +import { + RequestError, + type AgentSideConnection, + type SessionNotification, +} from '@agentclientprotocol/sdk'; +import { + ErrorCodes, + KimiError, + type ApprovalRequest, + type Event, + type KimiConfig, + type KimiHarness, + type Session, + type SessionStatus, + type SessionSummary, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { KimiAcpAgent } from '#/acp/agent'; +import { KimiAcpSession } from '#/acp/session'; + +describe('KimiAcpAgent', () => { + it('advertises ACP capabilities and auth methods during initialize', async () => { + const { agent } = makeAgent(); + + const response = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { auth: { terminal: true } }, + }); + + expect(response).toMatchObject({ + protocolVersion: 1, + agentInfo: { name: 'kimi-code', title: 'Kimi Code', version: '0.0.0-test' }, + agentCapabilities: { + promptCapabilities: { image: true, embeddedContext: true }, + mcpCapabilities: { http: true }, + sessionCapabilities: { + close: {}, + configOptions: {}, + fork: {}, + list: {}, + resume: {}, + }, + }, + }); + expect(response.authMethods?.map((method) => method.id)).toContain('kimi-code-terminal'); + }); + + it('advertises terminal auth for the ACP registry validator capability shape', async () => { + const { agent } = makeAgent(); + + const response = await agent.initialize({ + protocolVersion: 1, + clientCapabilities: { + terminal: true, + _meta: { 'terminal-auth': true }, + }, + }); + + expect(response.authMethods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'kimi-code-terminal', + type: 'terminal', + }), + ]), + ); + }); + + it('creates SDK sessions with ACP-provided MCP servers', async () => { + const fake = makeAgent(); + + const response = await fake.agent.newSession({ + cwd: '/tmp/project', + mcpServers: [ + { + name: 'filesystem', + command: 'node', + args: ['server.mjs'], + env: [], + }, + ], + }); + + expect(response).toEqual({ + sessionId: 'ses_acp', + models: expectedModelState, + configOptions: expectedModelConfigOptions, + }); + expect(fake.harness.ensureConfigFile).toHaveBeenCalledTimes(1); + expect(fake.harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/project', + permission: 'manual', + metadata: { acp: true }, + mcpServers: { + filesystem: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + }, + }, + }); + }); + + it('sets the active ACP session model through the SDK session', async () => { + const session = makeSession(); + const fake = makeAgent({ session }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + + await expect( + fake.agent.unstable_setSessionModel({ + sessionId: 'ses_acp', + modelId: 'kimi-test', + }), + ).resolves.toEqual({}); + expect(session.setModel).toHaveBeenCalledWith('kimi-test'); + }); + + it('sets the stable ACP model config option through the SDK session', async () => { + let currentModel = 'kimi-test'; + const session = makeSession({ + getStatus: vi.fn(async () => statusWithModel(currentModel)), + setModel: vi.fn(async (modelId: string) => { + currentModel = modelId; + }), + }); + const fake = makeAgent({ + session, + config: configWithModels({ + 'kimi-test': { + provider: 'local', + model: 'kimi-test', + maxContextSize: 1000, + displayName: 'Kimi Test', + }, + 'kimi-flash': { + provider: 'local', + model: 'kimi-flash', + maxContextSize: 2000, + displayName: 'Kimi Flash', + }, + }), + }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + + await expect( + fake.agent.setSessionConfigOption({ + sessionId: 'ses_acp', + configId: 'model', + value: 'kimi-flash', + }), + ).resolves.toEqual({ + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'kimi-flash', + options: [ + { + value: 'kimi-test', + name: 'Kimi Test', + description: 'local/kimi-test (1000 context)', + }, + { + value: 'kimi-flash', + name: 'Kimi Flash', + description: 'local/kimi-flash (2000 context)', + }, + ], + }, + ], + }); + expect(session.setModel).toHaveBeenCalledWith('kimi-flash'); + }); + + it('maps unknown ACP config options to invalid params', async () => { + const session = makeSession(); + const fake = makeAgent({ session }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + + await expect( + fake.agent.setSessionConfigOption({ + sessionId: 'ses_acp', + configId: 'temperature', + value: 'low', + }), + ).rejects.toMatchObject({ + code: -32602, + }); + expect(session.setModel).not.toHaveBeenCalled(); + }); + + it('maps unknown ACP model selections to invalid params', async () => { + const session = makeSession({ + setModel: vi.fn(async () => { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + 'Model "matrix-model" is not configured in config.toml.', + ); + }), + }); + const fake = makeAgent({ session }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + + await expect( + fake.agent.unstable_setSessionModel({ + sessionId: 'ses_acp', + modelId: 'matrix-model', + }), + ).rejects.toMatchObject({ + code: -32602, + }); + }); + + it('resumes persisted sessions with ACP-provided MCP servers', async () => { + const session = makeSession(); + const fake = makeAgent({ + session, + sessions: [ + { + id: 'ses_acp', + workDir: '/tmp/project', + sessionDir: '/tmp/kimi/sessions/ses_acp', + createdAt: Date.parse('2026-05-29T01:00:00.000Z'), + updatedAt: Date.parse('2026-05-30T02:03:04.000Z'), + }, + ], + }); + + await expect( + fake.agent.resumeSession({ + sessionId: 'ses_acp', + cwd: '/tmp/project', + mcpServers: [ + { + type: 'http', + name: 'docs', + url: 'https://mcp.example.test', + headers: [], + }, + ], + }), + ).resolves.toEqual({ + models: expectedModelState, + configOptions: expectedModelConfigOptions, + }); + expect(fake.harness.resumeSession).toHaveBeenCalledWith({ + id: 'ses_acp', + mcpServers: { + docs: { + transport: 'http', + url: 'https://mcp.example.test', + }, + }, + }); + }); + + it('rejects resume requests whose cwd does not match the persisted session', async () => { + const fake = makeAgent({ + sessions: [ + { + id: 'ses_acp', + workDir: '/tmp/project', + sessionDir: '/tmp/kimi/sessions/ses_acp', + createdAt: Date.parse('2026-05-29T01:00:00.000Z'), + updatedAt: Date.parse('2026-05-30T02:03:04.000Z'), + }, + ], + }); + + await expect( + fake.agent.resumeSession({ + sessionId: 'ses_acp', + cwd: '/tmp/other', + mcpServers: [], + }), + ).rejects.toMatchObject({ + code: -32602, + }); + expect(fake.harness.resumeSession).not.toHaveBeenCalled(); + }); + + it('forks persisted sessions with ACP-provided MCP servers', async () => { + const forkedSession = makeSession({ id: 'ses_fork' }); + const fake = makeAgent({ + forkedSession, + sessions: [ + { + id: 'ses_acp', + workDir: '/tmp/project', + sessionDir: '/tmp/kimi/sessions/ses_acp', + createdAt: Date.parse('2026-05-29T01:00:00.000Z'), + updatedAt: Date.parse('2026-05-30T02:03:04.000Z'), + }, + ], + }); + + await expect( + fake.agent.unstable_forkSession({ + sessionId: 'ses_acp', + cwd: '/tmp/project', + mcpServers: [ + { + name: 'filesystem', + command: 'node', + args: ['server.mjs'], + env: [], + }, + ], + }), + ).resolves.toEqual({ + sessionId: 'ses_fork', + models: expectedModelState, + configOptions: expectedModelConfigOptions, + }); + expect(fake.harness.forkSession).toHaveBeenCalledWith({ + id: 'ses_acp', + mcpServers: { + filesystem: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + }, + }, + }); + }); + + it('lists persisted sessions as ACP session info', async () => { + const fake = makeAgent({ + sessions: [ + { + id: 'ses_recent', + workDir: '/tmp/project', + sessionDir: '/tmp/kimi/sessions/ses_recent', + title: 'Recent work', + createdAt: Date.parse('2026-05-29T01:00:00.000Z'), + updatedAt: Date.parse('2026-05-30T02:03:04.000Z'), + metadata: { acp: true }, + }, + ], + }); + + await expect(fake.agent.listSessions({ cwd: '/tmp/project' })).resolves.toEqual({ + sessions: [ + { + sessionId: 'ses_recent', + cwd: '/tmp/project', + title: 'Recent work', + updatedAt: '2026-05-30T02:03:04.000Z', + }, + ], + }); + expect(fake.harness.ensureConfigFile).toHaveBeenCalledTimes(1); + expect(fake.harness.listSessions).toHaveBeenCalledWith({ workDir: '/tmp/project' }); + }); + + it('rejects unsupported session/list cursors before touching the harness', async () => { + const fake = makeAgent(); + + await expect( + fake.agent.listSessions({ cwd: '/tmp/project', cursor: 'page-2' }), + ).rejects.toThrow(RequestError); + expect(fake.harness.listSessions).not.toHaveBeenCalled(); + }); + + it('streams user and assistant chunks for a prompt turn', async () => { + let listener: ((event: Event) => void) | undefined; + const session = makeSession({ + onEvent: (next) => { + listener = next; + return () => {}; + }, + prompt: vi.fn(async () => { + listener?.({ + type: 'turn.started', + turnId: 1, + origin: { kind: 'user' }, + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + listener?.({ + type: 'assistant.delta', + turnId: 1, + delta: 'hello', + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + listener?.({ + type: 'turn.ended', + turnId: 1, + reason: 'completed', + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + }), + }); + const fake = makeAgent({ session }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + const response = await fake.agent.prompt({ + sessionId: 'ses_acp', + messageId: '00000000-0000-4000-8000-000000000000', + prompt: [{ type: 'text', text: 'hi' }], + }); + + expect(response).toEqual({ + stopReason: 'end_turn', + userMessageId: '00000000-0000-4000-8000-000000000000', + }); + expect(fake.connection.sessionUpdate).toHaveBeenNthCalledWith(1, { + sessionId: 'ses_acp', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hi' }, + messageId: '00000000-0000-4000-8000-000000000000', + }, + }); + expect(fake.connection.sessionUpdate).toHaveBeenNthCalledWith(2, { + sessionId: 'ses_acp', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }); + }); + + it('waits for turn completion before resolving a prompt request', async () => { + let listener: ((event: Event) => void) | undefined; + const session = makeSession({ + onEvent: (next) => { + listener = next; + return () => {}; + }, + prompt: vi.fn(async () => {}), + }); + const fake = makeAgent({ session }); + + await fake.agent.newSession({ cwd: '/tmp/project', mcpServers: [] }); + let resolved = false; + const promptPromise = fake.agent.prompt({ + sessionId: 'ses_acp', + messageId: '00000000-0000-4000-8000-000000000001', + prompt: [{ type: 'text', text: 'wait' }], + }).then((response) => { + resolved = true; + return response; + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + + listener?.({ + type: 'turn.started', + turnId: 3, + origin: { kind: 'user' }, + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + listener?.({ + type: 'assistant.delta', + turnId: 3, + delta: 'done', + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + await Promise.resolve(); + expect(resolved).toBe(false); + + listener?.({ + type: 'turn.ended', + turnId: 3, + reason: 'completed', + sessionId: 'ses_acp', + agentId: 'main', + } as Event); + + await expect(promptPromise).resolves.toEqual({ + stopReason: 'end_turn', + userMessageId: '00000000-0000-4000-8000-000000000001', + }); + expect(fake.connection.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'ses_acp', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + }, + }); + }); + + it('unblocks pending permission requests when the session is cancelled', async () => { + let approvalHandler: ((request: ApprovalRequest) => unknown) | undefined; + let resolvePermission: + | ((value: { outcome: { outcome: 'selected'; optionId: 'allow_once' } }) => void) + | undefined; + const session = makeSession({ + setApprovalHandler: vi.fn((handler: Parameters[0]) => { + approvalHandler = handler as typeof approvalHandler; + }), + }); + const connection = { + sessionUpdate: vi.fn(async (_params: SessionNotification) => {}), + requestPermission: vi.fn( + () => + new Promise<{ outcome: { outcome: 'selected'; optionId: 'allow_once' } }>((resolve) => { + resolvePermission = resolve; + }), + ), + }; + const acpSession = new KimiAcpSession( + session, + connection as unknown as AgentSideConnection, + ); + + const approval = approvalHandler?.({ + turnId: 2, + toolCallId: 'tc_permission', + toolName: 'Write', + action: 'Write file', + display: { + kind: 'file_io', + operation: 'write', + path: '/tmp/project/file.txt', + content: 'hello', + }, + }); + await acpSession.cancel(); + + await expect(approval).resolves.toEqual({ + decision: 'cancelled', + }); + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(connection.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'ses_acp', + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: '2:tc_permission', + status: 'failed', + }), + }); + resolvePermission?.({ outcome: { outcome: 'selected', optionId: 'allow_once' } }); + }); +}); + +function makeAgent( + options: { + session?: Session; + forkedSession?: Session; + sessions?: SessionSummary[]; + config?: KimiConfig; + } = {}, +) { + const session = options.session ?? makeSession(); + const connection = { + sessionUpdate: vi.fn(async (_params: SessionNotification) => {}), + requestPermission: vi.fn(async () => ({ outcome: { outcome: 'cancelled' as const } })), + }; + const harness = { + ensureConfigFile: vi.fn(async () => {}), + getConfig: vi.fn(async () => options.config ?? defaultConfig()), + createSession: vi.fn(async () => session), + resumeSession: vi.fn(async () => session), + forkSession: vi.fn(async () => options.forkedSession ?? session), + listSessions: vi.fn(async () => options.sessions ?? []), + auth: { + status: vi.fn(async () => ({ providers: [] })), + }, + close: vi.fn(async () => {}), + }; + + return { + agent: new KimiAcpAgent({ + connection: connection as unknown as AgentSideConnection, + version: '0.0.0-test', + harness: harness as unknown as KimiHarness, + }), + connection, + harness, + }; +} + +function makeSession(overrides: Partial = {}): Session { + return { + id: 'ses_acp', + workDir: '/tmp/project', + onEvent: vi.fn(() => () => {}), + setApprovalHandler: vi.fn(), + prompt: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + getStatus: vi.fn(async () => statusWithModel('kimi-test')), + setModel: vi.fn(async () => {}), + close: vi.fn(async () => {}), + ...overrides, + } as unknown as Session; +} + +const expectedModelState = { + availableModels: [ + { + modelId: 'kimi-test', + name: 'Kimi Test', + description: 'local/kimi-test (1000 context)', + }, + ], + currentModelId: 'kimi-test', +}; + +const expectedModelConfigOptions = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'kimi-test', + options: [ + { + value: 'kimi-test', + name: 'Kimi Test', + description: 'local/kimi-test (1000 context)', + }, + ], + }, +]; + +function defaultConfig(): KimiConfig { + return configWithModels({ + 'kimi-test': { + provider: 'local', + model: 'kimi-test', + maxContextSize: 1000, + displayName: 'Kimi Test', + }, + }); +} + +function configWithModels(models: KimiConfig['models']): KimiConfig { + return { + providers: { + local: { + type: 'kimi', + apiKey: 'sk-test', + }, + }, + defaultModel: 'kimi-test', + models, + }; +} + +function statusWithModel(model: string): SessionStatus { + return { + model, + thinkingLevel: 'auto', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 1000, + contextUsage: 0, + }; +} diff --git a/apps/kimi-code/test/acp/stdio.test.ts b/apps/kimi-code/test/acp/stdio.test.ts new file mode 100644 index 00000000..c8059912 --- /dev/null +++ b/apps/kimi-code/test/acp/stdio.test.ts @@ -0,0 +1,108 @@ +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +describe('kimi acp stdio transport', () => { + it('responds to initialize with JSON-RPC only on stdout', async () => { + const homeDir = await makeTempDir(); + const result = await runSourceAcpOnce(homeDir, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { + name: 'stdio-test', + version: '0.0.0', + }, + }, + }); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + const lines = result.stdout.trim().split('\n').filter(Boolean); + expect(lines).toHaveLength(1); + const message = JSON.parse(lines[0]!) as { + readonly id?: unknown; + readonly result?: { readonly protocolVersion?: unknown }; + }; + expect(message).toMatchObject({ + id: 1, + result: { + protocolVersion: 1, + }, + }); + }); +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'kimi-acp-stdio-')); + tempDirs.push(dir); + return dir; +} + +async function runSourceAcpOnce( + homeDir: string, + message: Record, +): Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }> { + const appRoot = path.resolve(import.meta.dirname, '../..'); + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + '--import', + path.resolve(appRoot, '../../build/register-raw-text-loader.mjs'), + path.join(appRoot, 'src/main.ts'), + 'acp', + ], + { + cwd: appRoot, + env: { + ...process.env, + KIMI_CODE_HOME: homeDir, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + child.stdin.end(`${JSON.stringify(message)}\n`); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error('timed out waiting for ACP initialize response')); + }, 10_000); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ code, stdout, stderr }); + }); + }); +} diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index ab02ed02..8c21ed37 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -8,7 +8,8 @@ import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; import { runUpdatePreflight } from '#/cli/update/preflight'; -import { handleMainCommand, main } from '#/main'; +import { handleAcpCommand, handleMainCommand, main } from '#/main'; +import { runAcpServer } from '../../src/acp/index'; const mocks = vi.hoisted(() => { const parse = vi.fn(); @@ -20,6 +21,7 @@ const mocks = vi.hoisted(() => { runUpdatePreflight: vi.fn(), runShell: vi.fn(), runPrompt: vi.fn(), + runAcpServer: vi.fn(), installCrashHandlers: vi.fn(), }; }); @@ -57,6 +59,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/acp/index', () => ({ + runAcpServer: mocks.runAcpServer, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -155,6 +161,17 @@ describe('main entry command handling', () => { expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); + it('runs ACP server without update preflight', async () => { + mocks.runAcpServer.mockResolvedValue(void 0); + + await handleAcpCommand('0.0.1-alpha.2'); + + expect(runAcpServer).toHaveBeenCalledWith({ version: '0.0.1-alpha.2' }); + expect(runUpdatePreflight).not.toHaveBeenCalled(); + expect(runShell).not.toHaveBeenCalled(); + expect(runPrompt).not.toHaveBeenCalled(); + }); + it('installs crash handlers before parsing CLI arguments', () => { main(); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 0a8fd774..9e00ebb2 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -109,6 +109,32 @@ describe('CLI options parsing', () => { }); }); + describe('ACP command', () => { + it('routes acp without calling the main action', () => { + const acp = { calls: 0 }; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + acp.calls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'kimi', 'acp']); + + expect(acp.calls).toBe(1); + }); + }); + describe('--yolo family', () => { it('--yolo sets yolo to true', () => { expect(parse(['--yolo']).yolo).toBe(true); @@ -261,7 +287,7 @@ describe('CLI options parsing', () => { const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'migrate']); + expect(commandNames).toEqual(['export', 'migrate', 'acp']); }); }); diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index cca98e03..3ac97f79 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -152,6 +152,32 @@ kimi export 01HZ...XYZ -o ./bug-report.zip kimi export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log ``` +### `kimi acp` + +Run Kimi Code CLI as an [Agent Client Protocol](https://agentclientprotocol.com/) agent server over stdio. This mode is intended for ACP-compatible editors and tools that launch Kimi Code as a subprocess. + +```sh +kimi acp +``` + +In ACP mode, stdout is reserved for newline-delimited JSON-RPC protocol messages. Logs, diagnostics, authentication errors, and progress text are written to stderr or the diagnostic log instead. The command does not open the TUI and does not run the interactive update preflight. + +Configure your ACP client to run the `kimi` binary with `acp` as its only argument: + +```json +{ + "agent_servers": { + "Kimi Code": { + "type": "custom", + "command": "kimi", + "args": ["acp"] + } + } +} +``` + +If Kimi Code is not authenticated or no default model is configured, `kimi acp` reports that through ACP authentication errors instead of printing setup instructions to stdout. Run `kimi` in a terminal and use `/login`, or configure `KIMI_MODEL_*` environment variables for the ACP server process. + ### `kimi migrate` Migrate local data from an older version of kimi-cli to kimi-code. This command has no flags and runs fully interactively, guiding you through the entire migration process. diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index d50c9ae0..da36a43f 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -152,6 +152,32 @@ kimi export 01HZ...XYZ -o ./bug-report.zip kimi export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log ``` +### `kimi acp` + +以 [Agent Client Protocol](https://agentclientprotocol.com/) agent server 形式通过 stdio 运行 Kimi Code CLI。该模式面向兼容 ACP 的编辑器和工具,它们会把 Kimi Code 作为子进程启动。 + +```sh +kimi acp +``` + +在 ACP 模式下,stdout 只用于输出以换行分隔的 JSON-RPC 协议消息。日志、诊断、认证错误和进度文本会写到 stderr 或诊断日志。该命令不会打开 TUI,也不会运行交互式更新预检。 + +在 ACP client 中,将 `command` 配置为 `kimi`,并把 `acp` 作为唯一参数: + +```json +{ + "agent_servers": { + "Kimi Code": { + "type": "custom", + "command": "kimi", + "args": ["acp"] + } + } +} +``` + +如果 Kimi Code 尚未认证或没有配置默认模型,`kimi acp` 会通过 ACP 认证错误上报,而不会向 stdout 打印设置说明。你可以在终端中运行 `kimi` 并使用 `/login`,也可以为 ACP server 进程配置 `KIMI_MODEL_*` 环境变量。 + ### `kimi migrate` 将旧版 kimi-cli 的本地数据迁移到 kimi-code。该命令无任何 flag,纯交互式运行,会引导你完成数据迁移的全流程。 diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 504e9a30..b70c3869 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -3,7 +3,7 @@ import type { AgentContextData } from '#/agent/context'; import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { ToolInfo } from '#/agent/tool'; -import type { KimiConfig, KimiConfigPatch } from '#/config'; +import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; import type { ExperimentalFlagMap } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; @@ -37,6 +37,7 @@ export interface CreateSessionPayload { readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; readonly metadata?: JsonObject | undefined; + readonly mcpServers?: Record; } export interface CloseSessionPayload { @@ -45,6 +46,7 @@ export interface CloseSessionPayload { export interface ResumeSessionPayload { readonly sessionId: string; + readonly mcpServers?: Record; } export interface ForkSessionPayload { @@ -52,6 +54,7 @@ export interface ForkSessionPayload { readonly id?: string; readonly title?: string; readonly metadata?: JsonObject; + readonly mcpServers?: Record; } export interface ShellEnvironment { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 26e0f7aa..22a55032 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -188,7 +188,7 @@ export class KimiCore implements PromisableMethods { await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); - const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig); + const mcpConfig = this.mergeMcpConfig(baseMcpConfig, options.mcpServers); // Session ctor attaches its own log sink. If anything in the setup-after- // ctor block throws, `session.close()` releases the sink (and mcp). @@ -277,7 +277,7 @@ export class KimiCore implements PromisableMethods { }); await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); - const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig); + const mcpConfig = this.mergeMcpConfig(baseMcpConfig, input.mcpServers); const runtime = await this.resolveRuntime(config); const session = new Session({ kaos: (await this.kaos).withCwd(summary.workDir), @@ -327,7 +327,7 @@ export class KimiCore implements PromisableMethods { title: input.title, metadata: input.metadata, }); - return this.resumeSession({ sessionId: id }); + return this.resumeSession({ sessionId: id, mcpServers: input.mcpServers }); } async listSessions(input: ListSessionsPayload = {}): Promise { @@ -681,13 +681,19 @@ export class KimiCore implements PromisableMethods { }); } - private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined { + private mergeMcpConfig( + base: SessionMcpConfig | undefined, + sessionServers?: SessionMcpConfig['servers'], + ): SessionMcpConfig | undefined { const pluginServers = this.plugins.enabledMcpServers(); - if (Object.keys(pluginServers).length === 0) return base; + if (Object.keys(pluginServers).length === 0 && Object.keys(sessionServers ?? {}).length === 0) { + return base; + } return { servers: { ...base?.servers, ...pluginServers, + ...sessionServers, }, }; } diff --git a/packages/agent-core/test/rpc/create-session-mcp.test.ts b/packages/agent-core/test/rpc/create-session-mcp.test.ts new file mode 100644 index 00000000..55357f2d --- /dev/null +++ b/packages/agent-core/test/rpc/create-session-mcp.test.ts @@ -0,0 +1,140 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { KimiCore } from '../../src/rpc/core-impl'; +import type { SDKRPC } from '../../src/rpc/sdk-api'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +describe('KimiCore.createSession MCP overrides', () => { + it('merges caller-provided MCP servers into the session config', async () => { + const homeDir = await makeTempDir('kimi-home-'); + const workDir = await makeTempDir('kimi-work-'); + const sdk: SDKRPC = { + emitEvent: vi.fn(), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' as const })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }; + const core = new KimiCore(async () => sdk, { homeDir }); + + const summary = await core.createSession({ + id: 'ses_acp_mcp', + workDir, + mcpServers: { + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }, + }); + + expect(core.sessions.get(summary.id)?.options.mcpConfig?.servers).toEqual({ + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }); + await core.closeSession({ sessionId: summary.id }); + }); + + it('merges caller-provided MCP servers when resuming a session', async () => { + const homeDir = await makeTempDir('kimi-home-'); + const workDir = await makeTempDir('kimi-work-'); + const sdk: SDKRPC = { + emitEvent: vi.fn(), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' as const })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }; + const core = new KimiCore(async () => sdk, { homeDir }); + + const summary = await core.createSession({ + id: 'ses_acp_resume_mcp', + workDir, + }); + await core.closeSession({ sessionId: summary.id }); + + await core.resumeSession({ + sessionId: summary.id, + mcpServers: { + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }, + }); + + expect(core.sessions.get(summary.id)?.options.mcpConfig?.servers).toEqual({ + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }); + await core.closeSession({ sessionId: summary.id }); + }); + + it('merges caller-provided MCP servers when forking a session', async () => { + const homeDir = await makeTempDir('kimi-home-'); + const workDir = await makeTempDir('kimi-work-'); + const sdk: SDKRPC = { + emitEvent: vi.fn(), + requestApproval: vi.fn(async () => ({ decision: 'cancelled' as const })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }; + const core = new KimiCore(async () => sdk, { homeDir }); + + const source = await core.createSession({ + id: 'ses_acp_fork_source', + workDir, + }); + await core.closeSession({ sessionId: source.id }); + + const forked = await core.forkSession({ + sessionId: source.id, + id: 'ses_acp_fork_target', + mcpServers: { + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }, + }); + + expect(core.sessions.get(forked.id)?.options.mcpConfig?.servers).toEqual({ + client: { + transport: 'stdio', + command: 'node', + args: ['server.mjs'], + enabled: false, + }, + }); + await core.closeSession({ sessionId: forked.id }); + }); +}); + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 769cd3a3..87ca597c 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -124,7 +124,10 @@ export class KimiHarness { const active = this.activeSessions.get(id); if (active !== undefined) return active; - const summary = await this.rpc.resumeSession({ id }); + const summary = await this.rpc.resumeSession({ + id, + mcpServers: input.mcpServers, + }); const session = new Session({ id: summary.id, workDir: summary.workDir, @@ -146,6 +149,7 @@ export class KimiHarness { forkId: input.forkId, title: input.title, metadata: input.metadata, + mcpServers: input.mcpServers, }); const session = new Session({ id: summary.id, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 7346e5a5..ecee7e91 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -150,7 +150,10 @@ export class SDKRpcClient { async resumeSession(input: ResumeSessionInput): Promise { const rpc = await this.getRpc(); - return rpc.resumeSession({ sessionId: input.id }); + return rpc.resumeSession({ + sessionId: input.id, + mcpServers: input.mcpServers, + }); } async forkSession(input: ForkSessionInput): Promise { @@ -160,6 +163,7 @@ export class SDKRpcClient { id: input.forkId, title: input.title, metadata: input.metadata, + mcpServers: input.mcpServers, }); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d9948e96..e4141c3f 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,5 +1,6 @@ import type { ExportSessionManifest, + McpServerConfig, ResumeSessionResult, ShellEnvironment, TelemetryClient, @@ -26,6 +27,7 @@ export type { KimiConfig, KimiConfigPatch, LoopControl, + McpServerConfig, McpServerInfo, McpStartupMetrics, ModelAlias, @@ -79,6 +81,7 @@ export interface CreateSessionOptions { readonly permission?: PermissionMode | undefined; readonly planMode?: boolean; readonly metadata?: JsonObject | undefined; + readonly mcpServers?: Record; } export interface RenameSessionInput { @@ -88,6 +91,7 @@ export interface RenameSessionInput { export interface ResumeSessionInput { readonly id: string; + readonly mcpServers?: Record; } export interface ForkSessionInput { @@ -95,6 +99,7 @@ export interface ForkSessionInput { readonly forkId?: string; readonly title?: string; readonly metadata?: JsonObject; + readonly mcpServers?: Record; } export interface ExportSessionInput { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 036de0d9..caf7c548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: apps/kimi-code: dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.22.1 + version: 0.22.1(zod@4.3.6) '@earendil-works/pi-tui': specifier: ^0.74.0 version: 0.74.0 @@ -399,6 +402,11 @@ importers: packages: + '@agentclientprotocol/sdk@0.22.1': + resolution: {integrity: sha512-DfqXtl/8gO9NImq094MTaCXEU2vkhh6v7q/kT+9UjZxUqj8hYaya2OjLVIqn16MzNHcXEpShTR2RIauLSYeDQQ==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@algolia/abtesting@1.18.1': resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} engines: {node: '>= 14.0.0'} @@ -5495,6 +5503,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@0.22.1(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@algolia/abtesting@1.18.1': dependencies: '@algolia/client-common': 5.52.1